Compare commits
4 Commits
feat/datat
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c31309a1db | ||
|
|
3c4c4d53dd | ||
|
|
9fe4554a70 | ||
|
|
c1dcabae20 |
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -327,7 +327,7 @@ const loadTools = async ({
|
||||
const { onSearchResults, onGetHighlights } = options?.[Tools.web_search] ?? {};
|
||||
requestedTools[tool] = async () => {
|
||||
toolContextMap[tool] = `# \`${tool}\`:
|
||||
Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||
Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}${options.req?.body?.timezone ? `\nLocal Date & Time: ${replaceSpecialVars({ text: '{{local_datetime}}', timezone: options.req.body.timezone })}` : ''}
|
||||
1. **Execute immediately without preface** when using \`${tool}\`.
|
||||
2. **After the search, begin with a brief summary** that directly addresses the query without headers or explaining your process.
|
||||
3. **Structure your response clearly** using Markdown formatting (Level 2 headers for sections, lists for multiple points, tables for comparisons).
|
||||
|
||||
@@ -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);
|
||||
throw new Error('Error getting single conversation');
|
||||
return { message: 'Error getting single conversation' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,21 +151,13 @@ module.exports = {
|
||||
const result = await Conversation.bulkWrite(bulkOps);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('[bulkSaveConvos] Error saving conversations in bulk', error);
|
||||
logger.error('[saveBulkConversations] 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,
|
||||
sortBy = 'createdAt',
|
||||
sortDirection = 'desc',
|
||||
} = {},
|
||||
{ cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {},
|
||||
) => {
|
||||
const filters = [{ user }];
|
||||
if (isArchived) {
|
||||
@@ -192,77 +184,35 @@ module.exports = {
|
||||
filters.push({ conversationId: { $in: matchingIds } });
|
||||
} catch (error) {
|
||||
logger.error('[getConvosByCursor] Error during meiliSearch', error);
|
||||
throw new Error('Error during meiliSearch');
|
||||
return { message: '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) {
|
||||
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);
|
||||
}
|
||||
filters.push({ updatedAt: { $lt: new Date(cursor) } });
|
||||
}
|
||||
|
||||
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(sortObj)
|
||||
.sort({ updatedAt: order === 'asc' ? 1 : -1 })
|
||||
.limit(limit + 1)
|
||||
.lean();
|
||||
|
||||
let nextCursor = null;
|
||||
if (convos.length > limit) {
|
||||
const lastConvo = convos.pop();
|
||||
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');
|
||||
nextCursor = lastConvo.updatedAt.toISOString();
|
||||
}
|
||||
|
||||
return { conversations: convos, nextCursor };
|
||||
} catch (error) {
|
||||
logger.error('[getConvosByCursor] Error getting conversations', error);
|
||||
throw new Error('Error getting conversations');
|
||||
return { message: 'Error getting conversations' };
|
||||
}
|
||||
},
|
||||
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
|
||||
@@ -302,7 +252,7 @@ module.exports = {
|
||||
return { conversations: limited, nextCursor, convoMap };
|
||||
} catch (error) {
|
||||
logger.error('[getConvosQueried] Error getting conversations', error);
|
||||
throw new Error('Error fetching conversations');
|
||||
return { message: 'Error fetching conversations' };
|
||||
}
|
||||
},
|
||||
getConvo,
|
||||
@@ -319,7 +269,7 @@ module.exports = {
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[getConvoTitle] Error getting conversation title', error);
|
||||
throw new Error('Error getting conversation title');
|
||||
return { message: 'Error getting conversation title' };
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -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.86",
|
||||
"@librechat/agents": "^2.4.85",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
|
||||
@@ -43,6 +43,7 @@ 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';
|
||||
|
||||
@@ -30,8 +30,7 @@ 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 sortBy = req.query.sortBy || 'createdAt';
|
||||
const sortDirection = req.query.sortDirection || 'desc';
|
||||
const order = req.query.order || 'desc';
|
||||
|
||||
let tags;
|
||||
if (req.query.tags) {
|
||||
@@ -45,8 +44,7 @@ router.get('/', async (req, res) => {
|
||||
isArchived,
|
||||
tags,
|
||||
search,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
order,
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
|
||||
@@ -185,6 +185,7 @@ const initializeAgent = async ({
|
||||
agent.instructions = replaceSpecialVars({
|
||||
text: agent.instructions,
|
||||
user: req.user,
|
||||
timezone: req.body?.timezone,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^6.4.1",
|
||||
"vite": "^6.3.6",
|
||||
"vite-plugin-compression2": "^2.2.1",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"vite-plugin-pwa": "^0.21.2"
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
AgentListResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { renderAgentAvatar, clearMessagesCache } from '~/utils';
|
||||
import { useLocalize, useDefaultConvo } from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { renderAgentAvatar } from '~/utils';
|
||||
|
||||
interface SupportContact {
|
||||
name?: string;
|
||||
@@ -56,7 +56,10 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
|
||||
|
||||
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agent.id);
|
||||
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.setQueryData<t.TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
|
||||
/** Template with agent configuration */
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useOutletContext } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
|
||||
import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions, QueryKeys } from 'librechat-data-provider';
|
||||
import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider';
|
||||
import type t from 'librechat-data-provider';
|
||||
import type { ContextType } from '~/common';
|
||||
import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks';
|
||||
@@ -13,11 +13,11 @@ import MarketplaceAdminSettings from './MarketplaceAdminSettings';
|
||||
import { SidePanelProvider, useChatContext } from '~/Providers';
|
||||
import { SidePanelGroup } from '~/components/SidePanel';
|
||||
import { OpenSidebar } from '~/components/Chat/Menus';
|
||||
import { cn, clearMessagesCache } from '~/utils';
|
||||
import CategoryTabs from './CategoryTabs';
|
||||
import AgentDetail from './AgentDetail';
|
||||
import SearchBar from './SearchBar';
|
||||
import AgentGrid from './AgentGrid';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
interface AgentMarketplaceProps {
|
||||
@@ -224,7 +224,10 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||
window.open('/c/new', '_blank');
|
||||
return;
|
||||
}
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.setQueryData<t.TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConversation();
|
||||
};
|
||||
|
||||
@@ -156,6 +156,7 @@ 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({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import React, { memo, useState, useCallback, useMemo } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { CheckboxButton } from '@librechat/client';
|
||||
import { ArtifactModes } from 'librechat-data-provider';
|
||||
@@ -18,7 +18,6 @@ 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) {
|
||||
@@ -34,26 +33,11 @@ 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 });
|
||||
@@ -93,24 +77,21 @@ function Artifacts() {
|
||||
'border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10',
|
||||
'transition-colors',
|
||||
)}
|
||||
onClick={handleMenuButtonClick}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'ml-1 h-4 w-4 text-text-secondary transition-transform duration-300 md:ml-0.5',
|
||||
isButtonExpanded && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
<ChevronDown className="ml-1 h-4 w-4 text-text-secondary md:ml-0" />
|
||||
</Ariakit.MenuButton>
|
||||
|
||||
<Ariakit.Menu
|
||||
gutter={4}
|
||||
gutter={8}
|
||||
className={cn(
|
||||
'animate-popover-top-left z-50 flex min-w-[250px] flex-col rounded-xl',
|
||||
'border border-border-light bg-surface-secondary shadow-lg',
|
||||
'animate-popover z-50 flex max-h-[300px]',
|
||||
'flex-col overflow-auto overscroll-contain rounded-xl',
|
||||
'bg-surface-secondary px-1.5 py-1 text-text-primary shadow-lg',
|
||||
'border border-border-light',
|
||||
'min-w-[250px] outline-none',
|
||||
)}
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
portal
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="mb-2 text-xs font-medium text-text-secondary">
|
||||
@@ -125,16 +106,18 @@ function Artifacts() {
|
||||
event.stopPropagation();
|
||||
handleShadcnToggle();
|
||||
}}
|
||||
disabled={isCustomEnabled}
|
||||
className={cn(
|
||||
'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',
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
|
||||
@@ -147,15 +130,15 @@ function Artifacts() {
|
||||
handleCustomToggle();
|
||||
}}
|
||||
className={cn(
|
||||
'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',
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
</div>
|
||||
|
||||
@@ -90,8 +90,8 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
className={cn(
|
||||
'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',
|
||||
'animate-popover-left z-50 ml-3 flex min-w-[250px] flex-col rounded-xl',
|
||||
'border border-border-light bg-surface-secondary px-1.5 py-1 shadow-lg',
|
||||
)}
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
@@ -107,16 +107,18 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
|
||||
event.stopPropagation();
|
||||
handleShadcnToggle();
|
||||
}}
|
||||
disabled={isCustomEnabled}
|
||||
className={cn(
|
||||
'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',
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
|
||||
@@ -129,15 +131,15 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
|
||||
handleCustomToggle();
|
||||
}}
|
||||
className={cn(
|
||||
'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',
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
|
||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||
</div>
|
||||
</Ariakit.MenuItem>
|
||||
</div>
|
||||
|
||||
@@ -247,7 +247,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
)}
|
||||
>
|
||||
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
||||
{/* WIP */}
|
||||
<EditBadges
|
||||
isEditingChatBadges={isEditingBadges}
|
||||
handleCancelBadges={handleCancelBadges}
|
||||
|
||||
@@ -224,7 +224,6 @@ 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">
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { Database } from 'lucide-react';
|
||||
import { ArrowUpDown, Database } from 'lucide-react';
|
||||
import { FileSources, FileContext } from 'librechat-data-provider';
|
||||
import { Checkbox, OpenAIMinimalIcon, AzureMinimalIcon, useMediaQuery } from '@librechat/client';
|
||||
import {
|
||||
Button,
|
||||
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';
|
||||
@@ -55,7 +61,16 @@ export const columns: ColumnDef<TFile>[] = [
|
||||
accessorKey: 'filename',
|
||||
header: ({ column }) => {
|
||||
const localize = useLocalize();
|
||||
return <SortFilterHeader column={column} title={localize('com_ui_name')} />;
|
||||
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>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const file = row.original;
|
||||
@@ -85,7 +100,16 @@ export const columns: ColumnDef<TFile>[] = [
|
||||
accessorKey: 'updatedAt',
|
||||
header: ({ column }) => {
|
||||
const localize = useLocalize();
|
||||
return <SortFilterHeader column={column} title={localize('com_ui_date')} />;
|
||||
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>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
@@ -173,7 +197,16 @@ export const columns: ColumnDef<TFile>[] = [
|
||||
accessorKey: 'bytes',
|
||||
header: ({ column }) => {
|
||||
const localize = useLocalize();
|
||||
return <SortFilterHeader column={column} title={localize('com_ui_size')} />;
|
||||
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>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const suffix = ' MB';
|
||||
|
||||
@@ -3,7 +3,6 @@ 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();
|
||||
@@ -89,10 +88,7 @@ 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={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',
|
||||
)}
|
||||
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"
|
||||
/>
|
||||
{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>}
|
||||
|
||||
@@ -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,11 +307,10 @@ 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="size-5" />
|
||||
<Settings2 className="icon-md" />
|
||||
</div>
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { QueryKeys } from 'librechat-data-provider';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys, Constants } from 'librechat-data-provider';
|
||||
import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { clearMessagesCache } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function HeaderNewChat() {
|
||||
@@ -15,7 +15,10 @@ export default function HeaderNewChat() {
|
||||
window.open('/c/new', '_blank');
|
||||
return;
|
||||
}
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.setQueryData<TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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="stroke-brand-purple/25 dark:stroke-brand-purple/50 origin-[50%_50%] -rotate-90"
|
||||
className="origin-[50%_50%] -rotate-90 stroke-brand-purple/25 dark:stroke-brand-purple/50"
|
||||
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';
|
||||
|
||||
@@ -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 md:h-9',
|
||||
'group relative flex h-12 w-full items-center rounded-lg transition-colors duration-200 md:h-9',
|
||||
isActiveConvo ? 'bg-surface-active-alt' : 'hover:bg-surface-active-alt',
|
||||
)}
|
||||
role="button"
|
||||
|
||||
@@ -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 aria-[expanded=true]: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"
|
||||
>
|
||||
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
|
||||
<div className="relative flex">
|
||||
@@ -40,10 +40,11 @@ function AccountSettings() {
|
||||
</div>
|
||||
</Select.Select>
|
||||
<Select.SelectPopover
|
||||
className="popover-ui w-[305px] rounded-lg md:w-[235px]"
|
||||
className="popover-ui w-[235px]"
|
||||
style={{
|
||||
transformOrigin: 'bottom',
|
||||
translate: '0 -4px',
|
||||
marginRight: '0px',
|
||||
translate: '0px',
|
||||
}}
|
||||
>
|
||||
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="note">
|
||||
|
||||
@@ -5,7 +5,6 @@ import { QueryKeys, Constants } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useLocalize, useNewConvo } from '~/hooks';
|
||||
import { clearMessagesCache } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function MobileNav({
|
||||
@@ -58,7 +57,10 @@ export default function MobileNav({
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.setQueryData<TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConversation();
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Skeleton, useMediaQuery } from '@librechat/client';
|
||||
import { 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,12 +158,13 @@ const Nav = memo(
|
||||
const headerButtons = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Suspense fallback={<Skeleton className="h-10 w-10 rounded-xl" />}>
|
||||
<Suspense fallback={null}>
|
||||
<AgentMarketplaceButton isSmallScreen={isSmallScreen} toggleNav={toggleNavVisible} />
|
||||
</Suspense>
|
||||
{hasAccessToBookmarks && (
|
||||
<>
|
||||
<Suspense fallback={<Skeleton className="h-10 w-10 rounded-xl" />}>
|
||||
<div className="mt-1.5" />
|
||||
<Suspense fallback={null}>
|
||||
<BookmarkNav tags={tags} setTags={setTags} isSmallScreen={isSmallScreen} />
|
||||
</Suspense>
|
||||
</>
|
||||
@@ -228,7 +229,7 @@ const Nav = memo(
|
||||
isSearchLoading={isSearchLoading}
|
||||
/>
|
||||
</div>
|
||||
<Suspense fallback={<Skeleton className="mt-1 h-12 w-full rounded-xl" />}>
|
||||
<Suspense fallback={null}>
|
||||
<AccountSettings />
|
||||
</Suspense>
|
||||
</nav>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { QueryKeys, Constants } from 'librechat-data-provider';
|
||||
import { TooltipAnchor, NewChatIcon, MobileSidebar, Sidebar, Button } from '@librechat/client';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useLocalize, useNewConvo } from '~/hooks';
|
||||
import { clearMessagesCache } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function NewChat({
|
||||
@@ -34,7 +33,10 @@ export default function NewChat({
|
||||
window.open('/c/new', '_blank');
|
||||
return;
|
||||
}
|
||||
clearMessagesCache(queryClient, conversation?.conversationId);
|
||||
queryClient.setQueryData<TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConvo();
|
||||
navigate('/c/new', { state: { focusChat: true } });
|
||||
|
||||
@@ -119,16 +119,11 @@ function ImportConversations() {
|
||||
aria-labelledby="import-conversation-label"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Spinner className="mr-1 w-4" />
|
||||
<span>{localize('com_ui_importing')}</span>
|
||||
</>
|
||||
<Spinner className="mr-1 w-4" />
|
||||
) : (
|
||||
<>
|
||||
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
|
||||
<span>{localize('com_ui_import')}</span>
|
||||
</>
|
||||
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" />
|
||||
)}
|
||||
<span>{localize('com_ui_import')}</span>
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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 } from 'lucide-react';
|
||||
import { TrashIcon, MessageSquare, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
useToastContext,
|
||||
@@ -16,156 +19,84 @@ import {
|
||||
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 { NotificationSeverity } from '~/common';
|
||||
import { formatDate, cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { formatDate } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const DEFAULT_PARAMS: SharedLinksListParams = {
|
||||
pageSize: 25,
|
||||
pageSize: PAGE_SIZE,
|
||||
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 [isOpen, setIsOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||
|
||||
const isSearchEnabled = useRecoilValue(store.search);
|
||||
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
|
||||
useSharedLinksQuery(queryParams, {
|
||||
enabled: isOpen,
|
||||
keepPreviousData: true,
|
||||
staleTime: 30 * 1000,
|
||||
staleTime: 0,
|
||||
cacheTime: 5 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const [allKnownLinks, setAllKnownLinks] = useState<SharedLinkItem[]>([]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setAllKnownLinks([]);
|
||||
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
search: value,
|
||||
sortBy: sortField as 'title' | 'createdAt',
|
||||
sortDirection: sortOrder,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSortingChange = useCallback(
|
||||
(updater: SortingState | ((old: SortingState) => SortingState)) => {
|
||||
setSorting((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
const handleFilterChange = useCallback((value: string) => {
|
||||
const encodedValue = encodeURIComponent(value.trim());
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
search: encodedValue,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
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],
|
||||
const debouncedFilterChange = useMemo(
|
||||
() => debounce(handleFilterChange, 300),
|
||||
[handleFilterChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.pages) return;
|
||||
return () => {
|
||||
debouncedFilterChange.cancel();
|
||||
};
|
||||
}, [debouncedFilterChange]);
|
||||
|
||||
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]);
|
||||
const allLinks = useMemo(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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: (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,
|
||||
});
|
||||
onSuccess: async () => {
|
||||
setIsDeleteOpen(false);
|
||||
refetch();
|
||||
setDeleteRow(null);
|
||||
await refetch();
|
||||
},
|
||||
onError: () => {
|
||||
onError: (error) => {
|
||||
console.error('Delete error:', error);
|
||||
showToast({
|
||||
message: localize('com_ui_share_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
@@ -173,47 +104,94 @@ 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 || isFetchingNextPage) return;
|
||||
if (hasNextPage !== true || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
await fetchNextPage();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const effectiveIsLoading = isLoading && displayData.length === 0;
|
||||
const effectiveIsFetching = isFetchingNextPage;
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (!deleteRow?.shareId) {
|
||||
showToast({
|
||||
message: localize('com_ui_share_delete_error'),
|
||||
severity: NotificationSeverity.WARNING,
|
||||
});
|
||||
return;
|
||||
if (deleteRow) {
|
||||
handleDelete([deleteRow]);
|
||||
}
|
||||
deleteMutation.mutate({ shareId: deleteRow.shareId });
|
||||
}, [deleteMutation, deleteRow, localize, showToast]);
|
||||
setIsDeleteOpen(false);
|
||||
}, [deleteRow, handleDelete]);
|
||||
|
||||
const columns: TableColumn<Record<string, unknown>, unknown>[] = useMemo(
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||
const link = row as SharedLinkItem;
|
||||
return link.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>
|
||||
);
|
||||
},
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">{localize('com_ui_name')}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const link = row.original as SharedLinkItem;
|
||||
const { title, shareId } = link;
|
||||
const { title, shareId } = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/share/${shareId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center truncate text-blue-500 hover:underline"
|
||||
aria-label={localize('com_ui_open_link', { 0: title })}
|
||||
className="block truncate text-blue-500 hover:underline"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
@@ -221,123 +199,122 @@ export default function SharedLinks() {
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
className: 'min-w-[150px] flex-1',
|
||||
size: '35%',
|
||||
mobileSize: '50%',
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
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);
|
||||
},
|
||||
meta: {
|
||||
className: 'w-32 sm:w-40',
|
||||
desktopOnly: true,
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
accessorFn: (row: Record<string, unknown>): unknown => null,
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">
|
||||
{localize('com_assistants_actions')}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const link = row.original as SharedLinkItem;
|
||||
const { title, conversationId } = link;
|
||||
|
||||
header: () => {
|
||||
const isSorted = queryParams.sortBy === 'createdAt';
|
||||
const sortDirection = queryParams.sortDirection;
|
||||
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>
|
||||
<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>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
||||
meta: {
|
||||
className: 'w-24',
|
||||
size: '10%',
|
||||
mobileSize: '20%',
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'actions',
|
||||
header: () => (
|
||||
<Label className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm">
|
||||
{localize('com_assistants_actions')}
|
||||
</Label>
|
||||
),
|
||||
meta: {
|
||||
size: '7%',
|
||||
mobileSize: '25%',
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<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/${row.original.conversationId}`, '_blank');
|
||||
}}
|
||||
title={localize('com_ui_view_source')}
|
||||
>
|
||||
<MessageSquare 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={() => {
|
||||
setDeleteRow(row.original);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
title={localize('com_ui_delete')}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[isSmallScreen, localize],
|
||||
[isSmallScreen, localize, queryParams, handleSort],
|
||||
);
|
||||
|
||||
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>
|
||||
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
|
||||
<Button aria-labelledby="shared-links-label" variant="outline">
|
||||
{localize('com_ui_manage')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className={cn('w-11/12 max-w-6xl', isSmallScreen && 'px-1 pb-1')}>
|
||||
|
||||
<OGDialogContent
|
||||
title={localize('com_nav_my_files')}
|
||||
className="w-11/12 max-w-5xl bg-background text-text-primary shadow-2xl"
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_shared_links')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
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}
|
||||
data={allLinks}
|
||||
onDelete={handleDelete}
|
||||
filterColumn="title"
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
showCheckboxes={false}
|
||||
onFilterChange={debouncedFilterChange}
|
||||
filterValue={queryParams.search}
|
||||
isLoading={isLoading}
|
||||
enableSearch={isSearchEnabled}
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
@@ -345,15 +322,17 @@ export default function SharedLinks() {
|
||||
<OGDialogTemplate
|
||||
showCloseButton={false}
|
||||
title={localize('com_ui_delete_shared_link')}
|
||||
className="w-11/12 max-w-md"
|
||||
className="max-w-[450px]"
|
||||
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 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>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
|
||||
@@ -1,406 +1,26 @@
|
||||
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 { useState } from 'react';
|
||||
import { OGDialogTemplate, OGDialog, OGDialogTrigger, Button } from '@librechat/client';
|
||||
import ArchivedChatsTable from './ArchivedChatsTable';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
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() {
|
||||
export default function ArchivedChats() {
|
||||
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">
|
||||
<Label htmlFor="archived-chats-button" className="text-sm font-medium">
|
||||
{localize('com_nav_archived_chats')}
|
||||
</Label>
|
||||
<div>{localize('com_nav_archived_chats')}</div>
|
||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button
|
||||
id="archived-chats-button"
|
||||
variant="outline"
|
||||
aria-label={localize('com_ui_manage_archived_chats')}
|
||||
>
|
||||
<Button variant="outline" aria-label="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
|
||||
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'),
|
||||
}}
|
||||
title={localize('com_nav_archived_chats')}
|
||||
className="max-w-[1000px]"
|
||||
showCancelButton={false}
|
||||
main={<ArchivedChatsTable isOpen={isOpen} onOpenChange={setIsOpen} />}
|
||||
/>
|
||||
</OGDialog>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export default function OAuthSuccess() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-8">
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-8 text-center shadow-lg">
|
||||
<div className="w-full max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
|
||||
<h1 className="mb-4 text-3xl font-bold text-gray-900">
|
||||
{localize('com_ui_oauth_success_title') || 'Authentication Successful'}
|
||||
</h1>
|
||||
|
||||
@@ -187,7 +187,8 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) {
|
||||
value={searchValue}
|
||||
onChange={handleSearch}
|
||||
placeholder={localize('com_nav_plugin_search')}
|
||||
className="text-token-text-primary flex rounded-md border border-border-heavy bg-surface-tertiary py-2 pl-10 pr-2"
|
||||
className="
|
||||
text-token-text-primary flex rounded-md border border-border-heavy bg-surface-tertiary py-2 pl-10 pr-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,11 @@ type TPluginTooltipProps = {
|
||||
function PluginTooltip({ content, position }: TPluginTooltipProps) {
|
||||
return (
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side={position} className="w-80">
|
||||
<HoverCardContent side={position} className="w-80 ">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">{content}</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
|
||||
@@ -73,7 +73,8 @@ export default function VariableForm({
|
||||
|
||||
const mainText = useMemo(() => {
|
||||
const initialText = group.productionPrompt?.prompt ?? '';
|
||||
return replaceSpecialVars({ text: initialText, user });
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return replaceSpecialVars({ text: initialText, user, timezone });
|
||||
}, [group.productionPrompt?.prompt, user]);
|
||||
|
||||
const { allVariables, uniqueVariables, variableIndexMap } = useMemo(
|
||||
|
||||
@@ -22,7 +22,8 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
|
||||
|
||||
const mainText = useMemo(() => {
|
||||
const initialText = group?.productionPrompt?.prompt ?? '';
|
||||
return replaceSpecialVars({ text: initialText, user });
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return replaceSpecialVars({ text: initialText, user, timezone });
|
||||
}, [group?.productionPrompt?.prompt, user]);
|
||||
|
||||
if (!group) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageProps } from '~/common';
|
||||
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import Message from './Message';
|
||||
import store from '~/store';
|
||||
|
||||
|
||||
@@ -33,7 +33,9 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
|
||||
</label>
|
||||
</div>
|
||||
<div className="border-token-border-medium flex rounded-lg border text-sm hover:cursor-pointer">
|
||||
<div className="h-9 grow px-3 py-2">{localize(getAuthLocalizationKey(type))}</div>
|
||||
<div className="h-9 grow px-3 py-2">
|
||||
{localize(getAuthLocalizationKey(type))}
|
||||
</div>
|
||||
<div className="bg-token-border-medium w-px"></div>
|
||||
<button type="button" color="neutral" className="flex items-center gap-2 px-3">
|
||||
<svg
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function useAddedHelpers({
|
||||
store.messagesSiblingIdxFamily(latestMessage?.parentMessageId ?? null),
|
||||
);
|
||||
|
||||
const queryParam = paramId === 'new' ? paramId : (conversation?.conversationId ?? paramId ?? '');
|
||||
const queryParam = paramId === 'new' ? paramId : conversation?.conversationId ?? paramId ?? '';
|
||||
|
||||
const setMessages = useCallback(
|
||||
(messages: TMessage[]) => {
|
||||
|
||||
@@ -121,9 +121,11 @@ export default function useChatFunctions({
|
||||
let currentMessages: TMessage[] | null = overrideMessages ?? getMessages() ?? [];
|
||||
|
||||
if (conversation?.promptPrefix) {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
conversation.promptPrefix = replaceSpecialVars({
|
||||
text: conversation.promptPrefix,
|
||||
user,
|
||||
timezone,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys, Constants, dataService } from 'librechat-data-provider';
|
||||
import type { TConversation, TEndpointsConfig, TModelsConfig } from 'librechat-data-provider';
|
||||
import {
|
||||
getDefaultEndpoint,
|
||||
clearMessagesCache,
|
||||
buildDefaultConvo,
|
||||
getEndpointField,
|
||||
logger,
|
||||
} from '~/utils';
|
||||
import { buildDefaultConvo, getDefaultEndpoint, getEndpointField, logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const useNavigateToConvo = (index = 0) => {
|
||||
@@ -86,7 +80,7 @@ const useNavigateToConvo = (index = 0) => {
|
||||
});
|
||||
}
|
||||
clearAllConversations(true);
|
||||
clearMessagesCache(queryClient, currentConvoId);
|
||||
queryClient.setQueryData([QueryKeys.messages, currentConvoId], []);
|
||||
if (convo.conversationId !== Constants.NEW_CONVO && convo.conversationId) {
|
||||
queryClient.invalidateQueries([QueryKeys.conversation, convo.conversationId]);
|
||||
fetchFreshData(convo);
|
||||
|
||||
@@ -81,7 +81,8 @@ export default function useSubmitMessage() {
|
||||
|
||||
const submitPrompt = useCallback(
|
||||
(text: string) => {
|
||||
const parsedText = replaceSpecialVars({ text, user });
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const parsedText = replaceSpecialVars({ text, user, timezone });
|
||||
if (autoSendPrompts) {
|
||||
submitMessage({ text: parsedText });
|
||||
return;
|
||||
|
||||
@@ -33,8 +33,9 @@ export default function useContentHandler({ setMessages, getMessages }: TUseCont
|
||||
|
||||
const _messages = getMessages();
|
||||
const messages =
|
||||
_messages?.filter((m) => m.messageId !== messageId).map((msg) => ({ ...msg, thread_id })) ??
|
||||
[];
|
||||
_messages
|
||||
?.filter((m) => m.messageId !== messageId)
|
||||
.map((msg) => ({ ...msg, thread_id })) ?? [];
|
||||
const userMessage = messages[messages.length - 1] as TMessage | undefined;
|
||||
|
||||
const { initialResponse } = submission;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"chat_direction_left_to_right": "Chat direction set to left to right",
|
||||
"chat_direction_right_to_left": "Chat direction set to right to left",
|
||||
"chat_direction_left_to_right": "Left to Right",
|
||||
"chat_direction_right_to_left": "Right to Left",
|
||||
"com_a11y_ai_composing": "The AI is still composing.",
|
||||
"com_a11y_end": "The AI has finished their reply.",
|
||||
"com_a11y_start": "The AI has started their reply.",
|
||||
@@ -387,10 +387,10 @@
|
||||
"com_files_downloading": "Downloading Files",
|
||||
"com_files_filter": "Filter files...",
|
||||
"com_files_no_results": "No results.",
|
||||
"com_files_number_selected": "{{0}} of {{1}} items selected",
|
||||
"com_files_number_selected": "{{0}} of {{1}} items(s) selected",
|
||||
"com_files_preparing_download": "Preparing download...",
|
||||
"com_files_sharepoint_picker_title": "Pick Files",
|
||||
"com_files_table": "Files Table",
|
||||
"com_files_table": "something needs to go here. was empty",
|
||||
"com_files_upload_local_machine": "From Local Computer",
|
||||
"com_files_upload_sharepoint": "From SharePoint",
|
||||
"com_generated_files": "Generated files:",
|
||||
@@ -402,7 +402,6 @@
|
||||
"com_nav_archive_created_at": "Date Archived",
|
||||
"com_nav_archive_name": "Name",
|
||||
"com_nav_archived_chats": "Archived chats",
|
||||
"com_ui_manage_archived_chats": "Manage archived chats",
|
||||
"com_nav_at_command": "@-Command",
|
||||
"com_nav_at_command_description": "Toggle command \"@\" for switching endpoints, models, presets, etc.",
|
||||
"com_nav_audio_play_error": "Error playing audio: {{0}}",
|
||||
@@ -749,6 +748,7 @@
|
||||
"com_ui_bookmarks_title": "Title",
|
||||
"com_ui_bookmarks_update_error": "There was an error updating the bookmark",
|
||||
"com_ui_bookmarks_update_success": "Bookmark updated successfully",
|
||||
"com_ui_bulk_delete_error": "Failed to delete shared links",
|
||||
"com_ui_callback_url": "Callback URL",
|
||||
"com_ui_cancel": "Cancel",
|
||||
"com_ui_cancelled": "Cancelled",
|
||||
@@ -832,7 +832,6 @@
|
||||
"com_ui_delete_not_allowed": "Delete operation is not allowed",
|
||||
"com_ui_delete_prompt": "Delete Prompt?",
|
||||
"com_ui_delete_shared_link": "Delete shared link?",
|
||||
"com_ui_delete_archived_chats": "Delete archived chat?",
|
||||
"com_ui_delete_success": "Successfully deleted",
|
||||
"com_ui_delete_tool": "Delete Tool",
|
||||
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
|
||||
@@ -955,7 +954,6 @@
|
||||
"com_ui_image_edited": "Image edited",
|
||||
"com_ui_image_gen": "Image Gen",
|
||||
"com_ui_import": "Import",
|
||||
"com_ui_importing": "Importing",
|
||||
"com_ui_import_conversation_error": "There was an error importing your conversations",
|
||||
"com_ui_import_conversation_file_type_error": "Unsupported import type",
|
||||
"com_ui_import_conversation_info": "Import conversations from a JSON file",
|
||||
@@ -1040,6 +1038,7 @@
|
||||
"com_ui_no_read_access": "You don't have permission to view memories",
|
||||
"com_ui_no_results_found": "No results found",
|
||||
"com_ui_no_terms_content": "No terms and conditions content to display",
|
||||
"com_ui_no_valid_items": "something needs to go here. was empty",
|
||||
"com_ui_none": "None",
|
||||
"com_ui_not_used": "Not Used",
|
||||
"com_ui_nothing_found": "Nothing found",
|
||||
@@ -1176,12 +1175,9 @@
|
||||
"com_ui_share_link_to_chat": "Share link to chat",
|
||||
"com_ui_share_update_message": "Your name, custom instructions, and any messages you add after sharing stay private.",
|
||||
"com_ui_share_var": "Share {{0}}",
|
||||
"com_ui_shared_link_bulk_delete_success": "Successfully deleted shared links",
|
||||
"com_ui_shared_link_delete_success": "Successfully deleted shared link",
|
||||
"com_ui_archived_conversation_delete_success": "Successfully deleted archived conversation",
|
||||
"com_ui_shared_link_not_found": "Shared link not found",
|
||||
"com_ui_open_link": "Open Link {{0}}",
|
||||
"com_ui_view_source_conversation": "View Source Conversation {{0}}",
|
||||
"com_ui_delete_link_title": "Delete Shared Link {{0}}",
|
||||
"com_ui_shared_prompts": "Shared Prompts",
|
||||
"com_ui_shop": "Shopping",
|
||||
"com_ui_show_all": "Show All",
|
||||
@@ -1303,8 +1299,5 @@
|
||||
"com_ui_zoom_in": "Zoom in",
|
||||
"com_ui_zoom_level": "Zoom level",
|
||||
"com_ui_zoom_out": "Zoom out",
|
||||
"com_ui_open_conversation": "Open conversation {{0}}",
|
||||
"com_ui_delete_conversation_title": "Delete conversation {{0}}",
|
||||
"com_ui_unarchive_conversation_title": "Unarchive conversation {{0}}",
|
||||
"com_user_message": "You"
|
||||
}
|
||||
|
||||
@@ -365,7 +365,6 @@
|
||||
"com_error_files_process": "处理文件时发生错误",
|
||||
"com_error_files_upload": "上传文件时发生错误",
|
||||
"com_error_files_upload_canceled": "文件上传请求已取消。注意:文件上传可能仍在进行中,需要手动删除。",
|
||||
"com_error_files_upload_too_large": "文件过大,请上传小于 {{0}} MB 的文件",
|
||||
"com_error_files_validation": "验证文件时出错。",
|
||||
"com_error_google_tool_conflict": "内置的 Google 工具与外部工具不兼容。请禁用内置工具或外部工具。",
|
||||
"com_error_heic_conversion": "将 HEIC 图片转换为 JPEG 失败。请尝试手动转换图像或使用其他格式。",
|
||||
@@ -561,7 +560,6 @@
|
||||
"com_nav_setting_balance": "余额",
|
||||
"com_nav_setting_chat": "对话",
|
||||
"com_nav_setting_data": "数据管理",
|
||||
"com_nav_setting_delay": "延迟(秒)",
|
||||
"com_nav_setting_general": "通用",
|
||||
"com_nav_setting_mcp": "MCP 设置",
|
||||
"com_nav_setting_personalization": "个性化",
|
||||
@@ -761,7 +759,6 @@
|
||||
"com_ui_client_secret": "Client Secret",
|
||||
"com_ui_close": "关闭",
|
||||
"com_ui_close_menu": "关闭菜单",
|
||||
"com_ui_close_settings": "关闭设置",
|
||||
"com_ui_close_window": "关闭窗口",
|
||||
"com_ui_code": "代码",
|
||||
"com_ui_collapse_chat": "收起对话",
|
||||
@@ -860,7 +857,6 @@
|
||||
"com_ui_edit_editing_image": "编辑图片",
|
||||
"com_ui_edit_mcp_server": "编辑 MCP 服务器",
|
||||
"com_ui_edit_memory": "编辑记忆",
|
||||
"com_ui_editor_instructions": "拖动图片调整位置 • 使用缩放滑块或按钮调整大小",
|
||||
"com_ui_empty_category": "-",
|
||||
"com_ui_endpoint": "端点",
|
||||
"com_ui_endpoint_menu": "LLM 端点菜单",
|
||||
@@ -895,7 +891,6 @@
|
||||
"com_ui_feedback_tag_unjustified_refusal": "无故拒绝回答",
|
||||
"com_ui_field_max_length": "{{field}} 最多 {{length}} 个字符",
|
||||
"com_ui_field_required": "此字段为必填项",
|
||||
"com_ui_file_input_avatar_label": "上传文件用作头像",
|
||||
"com_ui_file_size": "文件大小",
|
||||
"com_ui_file_token_limit": "文件词元数限制",
|
||||
"com_ui_file_token_limit_desc": "为文件处理设定最大词元数限制,以控制成本和资源使用",
|
||||
@@ -958,13 +953,11 @@
|
||||
"com_ui_import_conversation_file_type_error": "不支持的导入类型",
|
||||
"com_ui_import_conversation_info": "从 JSON 文件导入对话",
|
||||
"com_ui_import_conversation_success": "对话导入成功",
|
||||
"com_ui_import_conversation_upload_error": "上传文件时出错,请重试。",
|
||||
"com_ui_include_shadcnui": "包含 shadcn/ui 组件指令",
|
||||
"com_ui_initializing": "初始化中...",
|
||||
"com_ui_input": "输入",
|
||||
"com_ui_instructions": "指令",
|
||||
"com_ui_key": "键",
|
||||
"com_ui_key_required": "API Key 为必填项",
|
||||
"com_ui_late_night": "夜深了",
|
||||
"com_ui_latest_footer": "Every AI for Everyone.",
|
||||
"com_ui_latest_production_version": "最新在用版本",
|
||||
@@ -979,7 +972,6 @@
|
||||
"com_ui_manage": "管理",
|
||||
"com_ui_marketplace": "市场",
|
||||
"com_ui_marketplace_allow_use": "允许使用市场",
|
||||
"com_ui_max_file_size": "PNG、JPG 或 JPEG(最大 {{0}})",
|
||||
"com_ui_max_tags": "最多允许 {{0}} 个,用最新值。",
|
||||
"com_ui_mcp_authenticated_success": "MCP 服务器 “{{0}}” 认证成功",
|
||||
"com_ui_mcp_configure_server": "配置 {{0}}",
|
||||
@@ -1074,7 +1066,6 @@
|
||||
"com_ui_privacy_policy": "隐私政策",
|
||||
"com_ui_privacy_policy_url": "隐私政策链接",
|
||||
"com_ui_prompt": "提示词",
|
||||
"com_ui_prompt_groups": "提示词组列表",
|
||||
"com_ui_prompt_name": "提示词名称",
|
||||
"com_ui_prompt_name_required": "提示词名称为必填项",
|
||||
"com_ui_prompt_preview_not_shared": "作者未允许对此提示词进行协作。",
|
||||
@@ -1104,8 +1095,6 @@
|
||||
"com_ui_rename_failed": "重命名对话失败",
|
||||
"com_ui_rename_prompt": "重命名 Prompt",
|
||||
"com_ui_requires_auth": "需要认证",
|
||||
"com_ui_reset": "重置",
|
||||
"com_ui_reset_adjustments": "重置调整",
|
||||
"com_ui_reset_var": "重置 {{0}}",
|
||||
"com_ui_reset_zoom": "重置缩放",
|
||||
"com_ui_resource": "资源",
|
||||
@@ -1114,8 +1103,6 @@
|
||||
"com_ui_revoke_info": "撤销所有用户提供的凭据",
|
||||
"com_ui_revoke_key_confirm": "您确定要撤销此密钥吗?",
|
||||
"com_ui_revoke_key_endpoint": "撤销 {{0}} 的密钥",
|
||||
"com_ui_revoke_key_error": "撤销 API Key 失败,请重试。",
|
||||
"com_ui_revoke_key_success": "API Key 撤销成功",
|
||||
"com_ui_revoke_keys": "撤销密钥",
|
||||
"com_ui_revoke_keys_confirm": "您确定要撤销所有密钥吗?",
|
||||
"com_ui_role": "角色",
|
||||
@@ -1129,15 +1116,11 @@
|
||||
"com_ui_role_viewer": "查看者",
|
||||
"com_ui_role_viewer_desc": "可以查看和使用智能体,但无法修改智能体",
|
||||
"com_ui_roleplay": "角色扮演",
|
||||
"com_ui_rotate": "旋转",
|
||||
"com_ui_rotate_90": "旋转 90 度",
|
||||
"com_ui_run_code": "运行代码",
|
||||
"com_ui_run_code_error": "代码运行出错",
|
||||
"com_ui_save": "保存",
|
||||
"com_ui_save_badge_changes": "保存徽章更改?",
|
||||
"com_ui_save_changes": "保存修改",
|
||||
"com_ui_save_key_error": "保存 API Key 失败,请重试。",
|
||||
"com_ui_save_key_success": "API Key 保存成功",
|
||||
"com_ui_save_submit": "保存并提交",
|
||||
"com_ui_saved": "保存成功!",
|
||||
"com_ui_saving": "保存中...",
|
||||
@@ -1234,7 +1217,6 @@
|
||||
"com_ui_update_mcp_success": "已成功创建或更新 MCP",
|
||||
"com_ui_upload": "上传",
|
||||
"com_ui_upload_agent_avatar": "成功更新智能体头像",
|
||||
"com_ui_upload_avatar_label": "上传头像图片",
|
||||
"com_ui_upload_code_files": "上传代码解释器文件",
|
||||
"com_ui_upload_delay": "上传 “{{0}}” 时比预期花了更长时间。文件正在进行检索索引,请稍候。",
|
||||
"com_ui_upload_error": "上传文件错误",
|
||||
@@ -1246,7 +1228,6 @@
|
||||
"com_ui_upload_invalid": "上传的文件无效。必须是图片,且不得超过大小限制",
|
||||
"com_ui_upload_invalid_var": "上传的文件无效。必须是图片,且不得超过 {{0}} MB。",
|
||||
"com_ui_upload_ocr_text": "作为文本上传",
|
||||
"com_ui_upload_provider": "上传至提供商",
|
||||
"com_ui_upload_success": "上传文件成功",
|
||||
"com_ui_upload_type": "选择上传类型",
|
||||
"com_ui_usage": "用量",
|
||||
@@ -1286,8 +1267,6 @@
|
||||
"com_ui_web_search_scraper": "抓取器",
|
||||
"com_ui_web_search_scraper_firecrawl": "Firecrawl API",
|
||||
"com_ui_web_search_scraper_firecrawl_key": "获取您的 Firecrawl API Key",
|
||||
"com_ui_web_search_scraper_serper": "Serper Scrape API",
|
||||
"com_ui_web_search_scraper_serper_key": "获取您的 Serper API Key",
|
||||
"com_ui_web_search_searxng_api_key": "输入 SearXNG API Key(可选)",
|
||||
"com_ui_web_search_searxng_instance_url": "SearXNG 实例 URL",
|
||||
"com_ui_web_searching": "正在搜索网络",
|
||||
@@ -1297,8 +1276,5 @@
|
||||
"com_ui_x_selected": "{{0}} 已选择",
|
||||
"com_ui_yes": "是的",
|
||||
"com_ui_zoom": "缩放",
|
||||
"com_ui_zoom_in": "放大",
|
||||
"com_ui_zoom_level": "缩放级别",
|
||||
"com_ui_zoom_out": "缩小",
|
||||
"com_user_message": "您"
|
||||
}
|
||||
|
||||
@@ -2581,7 +2581,7 @@ html {
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
border-radius: 0.7rem;
|
||||
border-radius: 1rem;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--border-light);
|
||||
@@ -2654,7 +2654,6 @@ html {
|
||||
translate: 0;
|
||||
}
|
||||
|
||||
.animate-popover-top,
|
||||
.animate-popover {
|
||||
transform-origin: top;
|
||||
opacity: 0;
|
||||
@@ -2663,13 +2662,12 @@ html {
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translateY(-0.5rem);
|
||||
}
|
||||
.animate-popover-top[data-enter],
|
||||
|
||||
.animate-popover[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
/* Left (existing) */
|
||||
.animate-popover-left {
|
||||
transform-origin: left;
|
||||
opacity: 0;
|
||||
@@ -2678,92 +2676,12 @@ html {
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translateX(-0.5rem);
|
||||
}
|
||||
|
||||
.animate-popover-left[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateX(0);
|
||||
}
|
||||
|
||||
/* Right */
|
||||
.animate-popover-right {
|
||||
transform-origin: right;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translateX(0.5rem);
|
||||
}
|
||||
.animate-popover-right[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateX(0);
|
||||
}
|
||||
|
||||
/* Bottom */
|
||||
.animate-popover-bottom {
|
||||
transform-origin: bottom;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translateY(0.5rem);
|
||||
}
|
||||
.animate-popover-bottom[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
/* Corners */
|
||||
.animate-popover-top-left {
|
||||
transform-origin: top left;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translate(-0.5rem, -0.5rem);
|
||||
}
|
||||
.animate-popover-top-left[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translate(0, 0);
|
||||
}
|
||||
|
||||
.animate-popover-top-right {
|
||||
transform-origin: top right;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translate(0.5rem, -0.5rem);
|
||||
}
|
||||
.animate-popover-top-right[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translate(0, 0);
|
||||
}
|
||||
|
||||
.animate-popover-bottom-left {
|
||||
transform-origin: bottom left;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translate(-0.5rem, 0.5rem);
|
||||
}
|
||||
.animate-popover-bottom-left[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translate(0, 0);
|
||||
}
|
||||
|
||||
.animate-popover-bottom-right {
|
||||
transform-origin: bottom right;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform: scale(0.95) translate(0.5rem, 0.5rem);
|
||||
}
|
||||
.animate-popover-bottom-right[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translate(0, 0);
|
||||
}
|
||||
|
||||
/** Note: ensure KaTeX can spread across visible space */
|
||||
.message-content pre:has(> span.katex) {
|
||||
overflow: visible !important;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
import { EModelEndpoint, ImageDetail } from 'librechat-data-provider';
|
||||
import type { ConversationData } from 'librechat-data-provider';
|
||||
@@ -97,7 +98,7 @@ export const convoData: ConversationData = {
|
||||
promptPrefix: null,
|
||||
resendFiles: false,
|
||||
temperature: 1,
|
||||
title: "Write Einstein's Famous Equation in LaTeX",
|
||||
title: 'Write Einstein\'s Famous Equation in LaTeX',
|
||||
top_p: 1,
|
||||
updatedAt,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ContentTypes, QueryKeys, Constants } from 'librechat-data-provider';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const TEXT_KEY_DIVIDER = '|||';
|
||||
|
||||
@@ -147,26 +146,3 @@ export const scrollToEnd = (callback?: () => void) => {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears messages for both the specified conversation ID and the NEW_CONVO query key.
|
||||
* This ensures that messages are properly cleared in all contexts, preventing stale data
|
||||
* from persisting in the NEW_CONVO cache.
|
||||
*
|
||||
* @param queryClient - The React Query client instance
|
||||
* @param conversationId - The conversation ID to clear messages for
|
||||
*/
|
||||
export const clearMessagesCache = (
|
||||
queryClient: QueryClient,
|
||||
conversationId: string | undefined | null,
|
||||
): void => {
|
||||
const convoId = conversationId ?? Constants.NEW_CONVO;
|
||||
|
||||
// Clear messages for the current conversation
|
||||
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, convoId], []);
|
||||
|
||||
// Also clear NEW_CONVO messages if we're not already on NEW_CONVO
|
||||
if (convoId !== Constants.NEW_CONVO) {
|
||||
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, Constants.NEW_CONVO], []);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
// @ts-ignore
|
||||
import path from 'path';
|
||||
import type { Plugin } from 'vite';
|
||||
import { defineConfig } from 'vite';
|
||||
@@ -8,23 +7,19 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
const backendPort = process.env.BACKEND_PORT && Number(process.env.BACKEND_PORT) || 3080;
|
||||
const backendURL = process.env.HOST ? `http://${process.env.HOST}:${backendPort}` : `http://localhost:${backendPort}`;
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
base: '',
|
||||
server: {
|
||||
allowedHosts: process.env.VITE_ALLOWED_HOSTS && process.env.VITE_ALLOWED_HOSTS.split(',') || [],
|
||||
host: process.env.HOST || 'localhost',
|
||||
port: process.env.PORT && Number(process.env.PORT) || 3090,
|
||||
host: 'localhost',
|
||||
port: 3090,
|
||||
strictPort: false,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: backendURL,
|
||||
target: 'http://localhost:3080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/oauth': {
|
||||
target: backendURL,
|
||||
target: 'http://localhost:3080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
@@ -264,7 +259,6 @@ export default defineConfig(({ command }) => ({
|
||||
interface SourcemapExclude {
|
||||
excludeNodeModules?: boolean;
|
||||
}
|
||||
|
||||
export function sourcemapExclude(opts?: SourcemapExclude): Plugin {
|
||||
return {
|
||||
name: 'sourcemap-exclude',
|
||||
|
||||
@@ -62,7 +62,7 @@ const shouldRebase = process.argv.includes('--rebase');
|
||||
console.green('Your LibreChat app is now up to date! Start the app with the following command:');
|
||||
console.purple(startCommand);
|
||||
console.orange(
|
||||
"Note: it's also recommended to clear your browser cookies and localStorage for LibreChat to assure a fully clean installation.",
|
||||
'Note: it\'s also recommended to clear your browser cookies and localStorage for LibreChat to assure a fully clean installation.',
|
||||
);
|
||||
console.orange("Also: Don't worry, your data is safe :)");
|
||||
console.orange('Also: Don\'t worry, your data is safe :)');
|
||||
})();
|
||||
|
||||
145
package-lock.json
generated
145
package-lock.json
generated
@@ -19,7 +19,7 @@
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@microsoft/eslint-formatter-sarif": "^3.1.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@types/react-virtualized": "^9.22.0",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@langchain/google-genai": "^0.2.13",
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.86",
|
||||
"@librechat/agents": "^2.4.85",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
@@ -2768,7 +2768,7 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^6.4.1",
|
||||
"vite": "^6.3.6",
|
||||
"vite-plugin-compression2": "^2.2.1",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"vite-plugin-pwa": "^0.21.2"
|
||||
@@ -4305,24 +4305,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"client/node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"client/node_modules/framer-motion": {
|
||||
"version": "11.18.2",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
||||
@@ -4376,19 +4358,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"client/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"client/node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -4484,81 +4453,6 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"client/node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
"picomatch": "^4.0.2",
|
||||
"postcss": "^8.5.3",
|
||||
"rollup": "^4.34.9",
|
||||
"tinyglobby": "^0.2.13"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "*",
|
||||
"lightningcss": "^1.21.0",
|
||||
"sass": "*",
|
||||
"sass-embedded": "*",
|
||||
"stylus": "*",
|
||||
"sugarss": "*",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"lightningcss": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"client/node_modules/vite-plugin-pwa": {
|
||||
"version": "0.21.2",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.21.2.tgz",
|
||||
@@ -21637,9 +21531,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@librechat/agents": {
|
||||
"version": "2.4.86",
|
||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.86.tgz",
|
||||
"integrity": "sha512-Z3v+vMfFEyrDWrlPvgY9dUlhzYvtLXYYULEzkxUM1QpITuI3DsXr3xb1kXHAYOx3NmBGxiN9R/gjZN0tGBEo1g==",
|
||||
"version": "2.4.85",
|
||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-2.4.85.tgz",
|
||||
"integrity": "sha512-t6h5f6ApnoEC+x8kqBlke1RR6BPzT+9BvlkA8VxvQVJtYIt5Ey4BOTRDGjdilDoXUcLui11PbjCd17EbjPkTcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^0.3.26",
|
||||
@@ -23008,12 +22902,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
|
||||
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
|
||||
"version": "1.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.50.1.tgz",
|
||||
"integrity": "sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.56.1"
|
||||
"playwright": "1.50.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -43148,12 +43042,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
||||
"version": "1.50.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.1.tgz",
|
||||
"integrity": "sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.56.1"
|
||||
"playwright-core": "1.50.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -43166,9 +43060,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
||||
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
||||
"version": "1.50.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.50.1.tgz",
|
||||
"integrity": "sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
@@ -50079,7 +49973,6 @@
|
||||
"integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -50183,7 +50076,6 @@
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
@@ -50202,7 +50094,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -51446,7 +51337,7 @@
|
||||
"@azure/storage-blob": "^12.27.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/core": "^0.3.62",
|
||||
"@librechat/agents": "^2.4.86",
|
||||
"@librechat/agents": "^2.4.85",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
"axios": "^1.12.1",
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@microsoft/eslint-formatter-sarif": "^3.1.0",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@types/react-virtualized": "^9.22.0",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
"cross-env": "^7.0.3",
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"@azure/storage-blob": "^12.27.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/core": "^0.3.62",
|
||||
"@librechat/agents": "^2.4.86",
|
||||
"@librechat/agents": "^2.4.85",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
"axios": "^1.12.1",
|
||||
|
||||
@@ -176,14 +176,11 @@ export function formatToolContent(
|
||||
formattedContent.push({ type: 'text', text: currentTextBlock });
|
||||
}
|
||||
|
||||
let artifacts: t.Artifacts = imageUrls.length ? { content: imageUrls } : undefined;
|
||||
|
||||
if (uiResources.length) {
|
||||
let artifacts: t.Artifacts = undefined;
|
||||
if (imageUrls.length || uiResources.length) {
|
||||
artifacts = {
|
||||
...artifacts,
|
||||
[Tools.ui_resources]: {
|
||||
data: uiResources,
|
||||
},
|
||||
...(imageUrls.length && { content: imageUrls }),
|
||||
...(uiResources.length && { [Tools.ui_resources]: { data: uiResources } }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,6 @@ const plugins = [
|
||||
}),
|
||||
replace({
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
|
||||
'process.env.VITE_ENABLE_LOGGER': JSON.stringify(process.env.VITE_ENABLE_LOGGER || 'false'),
|
||||
'process.env.VITE_LOGGER_FILTER': JSON.stringify(process.env.VITE_LOGGER_FILTER || ''),
|
||||
preventAssignment: true,
|
||||
}),
|
||||
commonjs(),
|
||||
|
||||
@@ -10,7 +10,7 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-border-xheavy ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
468
packages/client/src/components/DataTable.tsx
Normal file
468
packages/client/src/components/DataTable.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import React, { useCallback, useEffect, useRef, useState, memo, useMemo } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import {
|
||||
Row,
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
VisibilityState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel,
|
||||
} from '@tanstack/react-table';
|
||||
import type { Table as TTable } from '@tanstack/react-table';
|
||||
import { Table, TableRow, TableBody, TableCell, TableHead, TableHeader } from './Table';
|
||||
import AnimatedSearchInput from './AnimatedSearchInput';
|
||||
import { TrashIcon, Spinner } from '~/svgs';
|
||||
import { useMediaQuery } from '~/hooks';
|
||||
import { Skeleton } from './Skeleton';
|
||||
import { Checkbox } from './Checkbox';
|
||||
import { Button } from './Button';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||
meta?: {
|
||||
size?: string | number;
|
||||
mobileSize?: string | number;
|
||||
minWidth?: string | number;
|
||||
};
|
||||
};
|
||||
|
||||
const SelectionCheckbox = memo(
|
||||
({
|
||||
checked,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
ariaLabel: string;
|
||||
}) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="flex h-full w-[30px] items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
SelectionCheckbox.displayName = 'SelectionCheckbox';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: TableColumn<TData, TValue>[];
|
||||
data: TData[];
|
||||
onDelete?: (selectedRows: TData[]) => Promise<void>;
|
||||
filterColumn?: string;
|
||||
defaultSort?: SortingState;
|
||||
columnVisibilityMap?: Record<string, string>;
|
||||
className?: string;
|
||||
pageSize?: number;
|
||||
isFetchingNextPage?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
fetchNextPage?: (options?: unknown) => Promise<unknown>;
|
||||
enableRowSelection?: boolean;
|
||||
showCheckboxes?: boolean;
|
||||
onFilterChange?: (value: string) => void;
|
||||
filterValue?: string;
|
||||
isLoading?: boolean;
|
||||
enableSearch?: boolean;
|
||||
}
|
||||
|
||||
const TableRowComponent = <TData, TValue>({
|
||||
row,
|
||||
isSmallScreen,
|
||||
onSelectionChange,
|
||||
index,
|
||||
isSearching,
|
||||
}: {
|
||||
row: Row<TData>;
|
||||
isSmallScreen: boolean;
|
||||
onSelectionChange?: (rowId: string, selected: boolean) => void;
|
||||
index: number;
|
||||
isSearching: boolean;
|
||||
}) => {
|
||||
const handleSelection = useCallback(
|
||||
(value: boolean) => {
|
||||
row.toggleSelected(value);
|
||||
onSelectionChange?.(row.id, value);
|
||||
},
|
||||
[row, onSelectionChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
className="motion-safe:animate-fadeIn border-b border-border-light transition-all duration-300 ease-out hover:bg-surface-secondary"
|
||||
style={{
|
||||
animationDelay: `${index * 20}ms`,
|
||||
transform: `translateY(${isSearching ? '4px' : '0'})`,
|
||||
opacity: isSearching ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
if (cell.column.id === 'select') {
|
||||
return (
|
||||
<TableCell key={cell.id} className="px-2 py-1 transition-all duration-300">
|
||||
<SelectionCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={handleSelection}
|
||||
ariaLabel="Select row"
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="w-0 max-w-0 px-2 py-1 align-middle text-xs transition-all duration-300 sm:px-4 sm:py-2 sm:text-sm"
|
||||
style={getColumnStyle(
|
||||
cell.column.columnDef as TableColumn<TData, TValue>,
|
||||
isSmallScreen,
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden text-ellipsis">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
|
||||
const MemoizedTableRow = memo(TableRowComponent) as typeof TableRowComponent;
|
||||
|
||||
function getColumnStyle<TData, TValue>(
|
||||
column: TableColumn<TData, TValue>,
|
||||
isSmallScreen: boolean,
|
||||
): React.CSSProperties {
|
||||
return {
|
||||
width: isSmallScreen ? column.meta?.mobileSize : column.meta?.size,
|
||||
minWidth: column.meta?.minWidth,
|
||||
maxWidth: column.meta?.size,
|
||||
};
|
||||
}
|
||||
|
||||
const DeleteButton = memo(
|
||||
({
|
||||
onDelete,
|
||||
isDeleting,
|
||||
disabled,
|
||||
isSmallScreen,
|
||||
}: {
|
||||
onDelete?: () => Promise<void>;
|
||||
isDeleting: boolean;
|
||||
disabled: boolean;
|
||||
isSmallScreen: boolean;
|
||||
}) => {
|
||||
if (!onDelete) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDelete}
|
||||
disabled={disabled}
|
||||
className={cn('min-w-[40px] transition-all duration-200', isSmallScreen && 'px-2 py-1')}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<>
|
||||
<TrashIcon className="size-3.5 text-red-400 sm:size-4" />
|
||||
{!isSmallScreen && <span className="ml-2">Delete</span>}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
onDelete,
|
||||
filterColumn,
|
||||
defaultSort = [],
|
||||
className = '',
|
||||
isFetchingNextPage = false,
|
||||
hasNextPage = false,
|
||||
fetchNextPage,
|
||||
enableRowSelection = true,
|
||||
showCheckboxes = true,
|
||||
onFilterChange,
|
||||
filterValue,
|
||||
isLoading,
|
||||
enableSearch = true,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [searchTerm, setSearchTerm] = useState(filterValue ?? '');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
if (!enableRowSelection || !showCheckboxes) {
|
||||
return columns;
|
||||
}
|
||||
const selectColumn = {
|
||||
id: 'select',
|
||||
header: ({ table }: { table: TTable<TData> }) => (
|
||||
<div className="flex h-full w-[30px] items-center justify-center">
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(Boolean(value))}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }: { row: Row<TData> }) => (
|
||||
<SelectionCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={(value) => row.toggleSelected(value)}
|
||||
ariaLabel="Select row"
|
||||
/>
|
||||
),
|
||||
meta: { size: '50px' },
|
||||
};
|
||||
return [selectColumn, ...columns];
|
||||
}, [columns, enableRowSelection, showCheckboxes]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: tableColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
enableRowSelection,
|
||||
enableMultiRowSelection: true,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
});
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: useCallback(() => 48, []),
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
|
||||
const paddingBottom =
|
||||
virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0;
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = tableContainerRef.current;
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = async () => {
|
||||
if (!hasNextPage || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
|
||||
try {
|
||||
// Safely fetch next page without breaking if lastPage is undefined
|
||||
await fetchNextPage?.();
|
||||
} catch (error) {
|
||||
console.error('Unable to fetch next page:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsSearching(true);
|
||||
const timeout = setTimeout(() => {
|
||||
onFilterChange?.(searchTerm);
|
||||
setIsSearching(false);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchTerm, onFilterChange]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!onDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const itemsToDelete = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
|
||||
await onDelete(itemsToDelete);
|
||||
setRowSelection({});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [onDelete, table]);
|
||||
|
||||
const getRandomWidth = () => Math.floor(Math.random() * (410 - 170 + 1)) + 170;
|
||||
|
||||
const skeletons = Array.from({ length: 13 }, (_, index) => {
|
||||
const randomWidth = getRandomWidth();
|
||||
const firstDataColumnIndex = tableColumns[0]?.id === 'select' ? 1 : 0;
|
||||
|
||||
return (
|
||||
<TableRow key={index} className="motion-safe:animate-fadeIn border-b border-border-light">
|
||||
{tableColumns.map((column, columnIndex) => {
|
||||
const style = getColumnStyle(column as TableColumn<TData, TValue>, isSmallScreen);
|
||||
const isFirstDataColumn = columnIndex === firstDataColumnIndex;
|
||||
|
||||
return (
|
||||
<TableCell key={column.id} className="px-2 py-1 sm:px-4 sm:py-2" style={style}>
|
||||
<Skeleton
|
||||
className="h-6"
|
||||
style={isFirstDataColumn ? { width: `${randomWidth}px` } : { width: '100%' }}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col gap-4', className)}>
|
||||
{/* Table controls */}
|
||||
<div className="flex flex-wrap items-center gap-2 sm:gap-4">
|
||||
{enableRowSelection && showCheckboxes && (
|
||||
<DeleteButton
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
)}
|
||||
{filterColumn !== undefined && table.getColumn(filterColumn) && enableSearch && (
|
||||
<div className="relative flex-1">
|
||||
<AnimatedSearchInput
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
isSearching={isSearching}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Virtualized table */}
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className={cn(
|
||||
'relative h-[calc(100vh-20rem)] max-w-full overflow-x-auto overflow-y-auto rounded-md border border-black/10 dark:border-white/10',
|
||||
'transition-all duration-300 ease-out',
|
||||
isSearching && 'bg-surface-secondary/50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Table className="w-full min-w-[300px] table-fixed border-separate border-spacing-0">
|
||||
<TableHeader className="sticky top-0 z-50 bg-surface-secondary">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="border-b border-border-light">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="whitespace-nowrap bg-surface-secondary px-2 py-2 text-left text-sm font-medium text-text-secondary sm:px-4"
|
||||
style={getColumnStyle(
|
||||
header.column.columnDef as TableColumn<TData, TValue>,
|
||||
isSmallScreen,
|
||||
)}
|
||||
onClick={
|
||||
header.column.getCanSort()
|
||||
? header.column.getToggleSortingHandler()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{paddingTop > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingTop}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{isLoading && skeletons}
|
||||
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<MemoizedTableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
isSmallScreen={isSmallScreen}
|
||||
index={virtualRow.index}
|
||||
isSearching={isSearching}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{!virtualRows.length && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="p-4 text-center">
|
||||
No data available
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{paddingBottom > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingBottom}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Loading indicator */}
|
||||
{(isFetchingNextPage || hasNextPage) && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={columns.length} className="p-4">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
{isFetchingNextPage ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
hasNextPage && <div className="h-6" />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import type { TableColumn } from './DataTable.types';
|
||||
|
||||
export function useDebounced<T>(value: T, delay: number) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export const useOptimizedRowSelection = (initialSelection: Record<string, boolean> = {}) => {
|
||||
const [selection, setSelection] = useState(initialSelection);
|
||||
return [selection, setSelection] as const;
|
||||
};
|
||||
|
||||
export const useColumnStyles = <TData, TValue>(
|
||||
columns: TableColumn<TData, TValue>[],
|
||||
isSmallScreen: boolean,
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
) => {
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
setContainerWidth(container.clientWidth);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(container);
|
||||
updateWidth();
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [containerRef]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (containerWidth === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {};
|
||||
let totalFixedWidth = 0;
|
||||
const flexibleColumns: (TableColumn<TData, TValue> & { priority: number })[] = [];
|
||||
|
||||
columns.forEach((column) => {
|
||||
const key = String(column.id ?? column.accessorKey ?? '');
|
||||
const size = isSmallScreen ? column.meta?.mobileSize : column.meta?.size;
|
||||
|
||||
if (size) {
|
||||
const width = parseInt(String(size), 10);
|
||||
totalFixedWidth += width;
|
||||
styles[key] = {
|
||||
width: size,
|
||||
minWidth: column.meta?.minWidth || size,
|
||||
};
|
||||
} else {
|
||||
flexibleColumns.push({ ...column, priority: column.meta?.priority ?? 1 });
|
||||
}
|
||||
});
|
||||
|
||||
const availableWidth = containerWidth - totalFixedWidth;
|
||||
const totalPriority = flexibleColumns.reduce((sum, col) => sum + col.priority, 0);
|
||||
|
||||
if (availableWidth > 0 && totalPriority > 0) {
|
||||
flexibleColumns.forEach((column) => {
|
||||
const key = String(column.id ?? column.accessorKey ?? '');
|
||||
const proportion = column.priority / totalPriority;
|
||||
const width = Math.max(Math.floor(availableWidth * proportion), 80); // min width of 80px
|
||||
styles[key] = {
|
||||
width: `${width}px`,
|
||||
minWidth: column.meta?.minWidth ?? `${isSmallScreen ? 60 : 80}px`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return styles;
|
||||
}, [columns, containerWidth, isSmallScreen]);
|
||||
};
|
||||
|
||||
export const useDynamicColumnWidths = useColumnStyles;
|
||||
|
||||
export const useKeyboardNavigation = (
|
||||
tableRef: React.RefObject<HTMLDivElement>,
|
||||
rowCount: number,
|
||||
onRowSelect?: (index: number) => void,
|
||||
) => {
|
||||
const [focusedRowIndex, setFocusedRowIndex] = useState<number>(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!tableRef.current?.contains(event.target as Node)) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setFocusedRowIndex((prev) => Math.min(prev + 1, rowCount - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setFocusedRowIndex((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
setFocusedRowIndex(0);
|
||||
break;
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
setFocusedRowIndex(rowCount - 1);
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
if (focusedRowIndex >= 0 && onRowSelect) {
|
||||
event.preventDefault();
|
||||
onRowSelect(focusedRowIndex);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setFocusedRowIndex(-1);
|
||||
(event.target as HTMLElement).blur();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [tableRef, rowCount, focusedRowIndex, onRowSelect]);
|
||||
|
||||
return { focusedRowIndex, setFocusedRowIndex };
|
||||
};
|
||||
@@ -1,609 +0,0 @@
|
||||
import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { ArrowUp, ArrowDown, ArrowDownUp } from 'lucide-react';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
type ColumnDef,
|
||||
type Row,
|
||||
type Table as TTable,
|
||||
} from '@tanstack/react-table';
|
||||
import type { DataTableProps, ProcessedDataRow } from './DataTable.types';
|
||||
import { SelectionCheckbox, MemoizedTableRow, SkeletonRows } from './DataTableComponents';
|
||||
import { Table, TableBody, TableHead, TableHeader, TableCell, TableRow } from '../Table';
|
||||
import { useDebounced, useOptimizedRowSelection } from './DataTable.hooks';
|
||||
import { useMediaQuery, useLocalize } from '~/hooks';
|
||||
import { DataTableSearch } from './DataTableSearch';
|
||||
import { cn, logger } from '~/utils';
|
||||
import { Label } from '../Label';
|
||||
import { Spinner } from '~/svgs';
|
||||
|
||||
function DataTable<TData extends Record<string, unknown>, TValue>({
|
||||
columns,
|
||||
data,
|
||||
className = '',
|
||||
isLoading = false,
|
||||
isFetching = false,
|
||||
config,
|
||||
filterValue = '',
|
||||
onFilterChange,
|
||||
defaultSort = [],
|
||||
isFetchingNextPage = false,
|
||||
hasNextPage = false,
|
||||
fetchNextPage,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
customActionsRenderer,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const localize = useLocalize();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollTimeoutRef = useRef<number | null>(null);
|
||||
const scrollRAFRef = useRef<number | null>(null);
|
||||
|
||||
const {
|
||||
selection: { enableRowSelection = true, showCheckboxes = true } = {},
|
||||
search: { enableSearch = true, debounce: debounceDelay = 300 } = {},
|
||||
skeleton: { count: skeletonCount = 10 } = {},
|
||||
virtualization: {
|
||||
overscan = 10,
|
||||
minRows = 50,
|
||||
rowHeight = 56,
|
||||
fastOverscanMultiplier = 4,
|
||||
} = {},
|
||||
} = config || {};
|
||||
|
||||
const virtualizationActive = data.length >= minRows;
|
||||
|
||||
// Dynamic overscan for fast scrolling - increases rendered rows during rapid scroll
|
||||
const [dynamicOverscan, setDynamicOverscan] = useState(overscan);
|
||||
const lastScrollTopRef = useRef(0);
|
||||
const lastScrollTimeRef = useRef(performance.now());
|
||||
const fastScrollTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDynamicOverscan(overscan);
|
||||
}, [overscan]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (fastScrollTimeoutRef.current) {
|
||||
clearTimeout(fastScrollTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection();
|
||||
const [searchTerm, setSearchTerm] = useState(filterValue);
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>(defaultSort);
|
||||
|
||||
const selectedCount = Object.keys(optimizedRowSelection).length;
|
||||
const isAllSelected = useMemo(
|
||||
() => data.length > 0 && selectedCount === data.length,
|
||||
[data.length, selectedCount],
|
||||
);
|
||||
const isIndeterminate = selectedCount > 0 && !isAllSelected;
|
||||
|
||||
const getRowId = useCallback(
|
||||
(row: TData, index?: number) => String(row.id ?? `row-${index ?? 0}`),
|
||||
[],
|
||||
);
|
||||
|
||||
const selectedRows = useMemo(() => {
|
||||
if (Object.keys(optimizedRowSelection).length === 0) return [];
|
||||
|
||||
const dataMap = new Map(data.map((item, index) => [getRowId(item, index), item]));
|
||||
return Object.keys(optimizedRowSelection)
|
||||
.map((id) => dataMap.get(id))
|
||||
.filter(Boolean) as TData[];
|
||||
}, [optimizedRowSelection, data, getRowId]);
|
||||
|
||||
const cleanupTimers = useCallback(() => {
|
||||
if (scrollRAFRef.current) {
|
||||
cancelAnimationFrame(scrollRAFRef.current);
|
||||
scrollRAFRef.current = null;
|
||||
}
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
scrollTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const debouncedTerm = useDebounced(searchTerm, debounceDelay);
|
||||
const finalSorting = sorting ?? internalSorting;
|
||||
|
||||
// Mobile column visibility: columns with desktopOnly meta are hidden via CSS on mobile
|
||||
// but remain in DOM for accessibility. CSS classes handle visual hiding.
|
||||
const calculatedVisibility = useMemo(() => {
|
||||
const newVisibility: VisibilityState = {};
|
||||
|
||||
columns.forEach((col) => {
|
||||
const meta = (col as { meta?: { desktopOnly?: boolean } }).meta;
|
||||
if (!meta?.desktopOnly) return;
|
||||
|
||||
const rawId =
|
||||
(col as { id?: string | number; accessorKey?: string | number }).id ??
|
||||
(col as { accessorKey?: string | number }).accessorKey;
|
||||
|
||||
if ((typeof rawId === 'string' || typeof rawId === 'number') && String(rawId).length > 0) {
|
||||
newVisibility[String(rawId)] = true;
|
||||
} else {
|
||||
logger.warn(
|
||||
'DataTable: A desktopOnly column is missing id/accessorKey; cannot control header visibility automatically.',
|
||||
col,
|
||||
);
|
||||
}
|
||||
});
|
||||
return newVisibility;
|
||||
}, [isSmallScreen, columns]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnVisibility((prev) => ({ ...prev, ...calculatedVisibility }));
|
||||
}, [calculatedVisibility]);
|
||||
|
||||
// Warn about missing row IDs - only once per component lifecycle
|
||||
const hasWarnedAboutMissingIds = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.length > 0 && !hasWarnedAboutMissingIds.current) {
|
||||
const missing = data.filter((item) => item.id === null || item.id === undefined);
|
||||
if (missing.length > 0) {
|
||||
logger.warn(
|
||||
`DataTable Warning: ${missing.length} data rows are missing a unique "id" property. Using index as a fallback. This can lead to unexpected behavior with selection and sorting.`,
|
||||
{ missingCount: missing.length, sample: missing.slice(0, 3) },
|
||||
);
|
||||
hasWarnedAboutMissingIds.current = true;
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const tableColumns = useMemo((): ColumnDef<TData, TValue>[] => {
|
||||
if (!enableRowSelection || !showCheckboxes) {
|
||||
return columns.map((col) => col as unknown as ColumnDef<TData, TValue>);
|
||||
}
|
||||
|
||||
const selectColumn: ColumnDef<TData, TValue> = {
|
||||
id: 'select',
|
||||
enableResizing: false,
|
||||
header: () => {
|
||||
const extraCheckboxProps = (isIndeterminate ? { indeterminate: true } : {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return (
|
||||
<div
|
||||
className="flex h-full items-center justify-center"
|
||||
aria-label={localize('com_ui_select_all')}
|
||||
>
|
||||
<SelectionCheckbox
|
||||
checked={isAllSelected}
|
||||
onChange={(value) => {
|
||||
if (isAllSelected || !value) {
|
||||
setOptimizedRowSelection({});
|
||||
} else {
|
||||
const allSelection = data.reduce<Record<string, boolean>>((acc, item, index) => {
|
||||
acc[getRowId(item, index)] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
setOptimizedRowSelection(allSelection);
|
||||
}
|
||||
}}
|
||||
ariaLabel={localize('com_ui_select_all')}
|
||||
{...extraCheckboxProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const rowDescription = row.original.name
|
||||
? `named ${row.original.name}`
|
||||
: `at position ${row.index + 1}`;
|
||||
return (
|
||||
<div
|
||||
className="flex h-full items-center justify-center"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={localize(`com_ui_select_row`, { rowDescription })}
|
||||
>
|
||||
<SelectionCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={(value) => row.toggleSelected(value)}
|
||||
ariaLabel={localize(`com_ui_select_row`, { rowDescription })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
className: 'max-w-[20px] flex-1',
|
||||
},
|
||||
};
|
||||
|
||||
return [selectColumn, ...columns.map((col) => col as unknown as ColumnDef<TData, TValue>)];
|
||||
}, [
|
||||
columns,
|
||||
enableRowSelection,
|
||||
showCheckboxes,
|
||||
localize,
|
||||
data,
|
||||
getRowId,
|
||||
isAllSelected,
|
||||
isIndeterminate,
|
||||
setOptimizedRowSelection,
|
||||
]);
|
||||
|
||||
const sizedColumns = tableColumns;
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
data,
|
||||
columns: sizedColumns,
|
||||
getRowId: getRowId,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
enableRowSelection,
|
||||
enableMultiRowSelection: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
state: {
|
||||
sorting: finalSorting,
|
||||
columnVisibility,
|
||||
rowSelection: optimizedRowSelection,
|
||||
},
|
||||
onSortingChange: onSortingChange ?? setInternalSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setOptimizedRowSelection,
|
||||
});
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
enabled: virtualizationActive,
|
||||
count: data.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
getItemKey: (index) => getRowId(data[index] as TData, index),
|
||||
estimateSize: useCallback(() => rowHeight, [rowHeight]),
|
||||
overscan: dynamicOverscan,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
const paddingTop = virtualRows[0]?.start ?? 0;
|
||||
const paddingBottom =
|
||||
virtualRows.length > 0 ? totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0) : 0;
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
const headerGroups = table.getHeaderGroups();
|
||||
|
||||
const showSkeletons = isLoading || (isFetching && !isFetchingNextPage);
|
||||
const shouldShowSearch = enableSearch && onFilterChange;
|
||||
|
||||
// Render table body based on loading state and virtualization
|
||||
let tableBodyContent: React.ReactNode;
|
||||
if (showSkeletons) {
|
||||
tableBodyContent = (
|
||||
<SkeletonRows
|
||||
count={skeletonCount}
|
||||
columns={tableColumns as ColumnDef<Record<string, unknown>>[]}
|
||||
/>
|
||||
);
|
||||
} else if (virtualizationActive) {
|
||||
tableBodyContent = (
|
||||
<>
|
||||
{paddingTop > 0 && (
|
||||
<TableRow aria-hidden="true">
|
||||
<TableCell
|
||||
colSpan={tableColumns.length}
|
||||
style={{ height: paddingTop, padding: 0, border: 0 }}
|
||||
/>
|
||||
</TableRow>
|
||||
)}
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
|
||||
return (
|
||||
<MemoizedTableRow
|
||||
key={virtualRow.key}
|
||||
row={row as unknown as Row<Record<string, unknown>>}
|
||||
virtualIndex={virtualRow.index}
|
||||
selected={row.getIsSelected()}
|
||||
style={{ height: rowHeight }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{paddingBottom > 0 && (
|
||||
<TableRow aria-hidden="true">
|
||||
<TableCell
|
||||
colSpan={tableColumns.length}
|
||||
style={{ height: paddingBottom, padding: 0, border: 0 }}
|
||||
/>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
tableBodyContent = rows.map((row) => (
|
||||
<MemoizedTableRow
|
||||
key={getRowId(row.original as TData, row.index)}
|
||||
row={row as unknown as Row<Record<string, unknown>>}
|
||||
virtualIndex={row.index}
|
||||
selected={row.getIsSelected()}
|
||||
style={{ height: rowHeight }}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(filterValue);
|
||||
}, [filterValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTerm !== filterValue && onFilterChange) {
|
||||
onFilterChange(debouncedTerm);
|
||||
setOptimizedRowSelection({});
|
||||
}
|
||||
}, [debouncedTerm, filterValue, onFilterChange, setOptimizedRowSelection]);
|
||||
|
||||
// Recalculate virtual range when data or state changes
|
||||
useEffect(() => {
|
||||
if (!virtualizationActive) return;
|
||||
rowVirtualizer.calculateRange();
|
||||
}, [data.length, finalSorting, columnVisibility, virtualizationActive, rowVirtualizer]);
|
||||
|
||||
// Recalculate when container is resized
|
||||
useEffect(() => {
|
||||
if (!virtualizationActive) return;
|
||||
const container = tableContainerRef.current;
|
||||
if (!container) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
rowVirtualizer.calculateRange();
|
||||
});
|
||||
ro.observe(container);
|
||||
return () => ro.disconnect();
|
||||
}, [virtualizationActive, rowVirtualizer]);
|
||||
|
||||
const handleScroll = useMemo(() => {
|
||||
let rafId: number | null = null;
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
return () => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
const container = tableContainerRef.current;
|
||||
if (container) {
|
||||
const now = performance.now();
|
||||
const delta = Math.abs(container.scrollTop - lastScrollTopRef.current);
|
||||
const dt = now - lastScrollTimeRef.current;
|
||||
if (dt > 0) {
|
||||
const velocity = delta / dt;
|
||||
// Increase overscan during fast scrolling for smoother experience
|
||||
if (velocity > 2 && virtualizationActive && dynamicOverscan === overscan) {
|
||||
if (fastScrollTimeoutRef.current) {
|
||||
window.clearTimeout(fastScrollTimeoutRef.current);
|
||||
}
|
||||
setDynamicOverscan(Math.min(overscan * fastOverscanMultiplier, overscan * 8));
|
||||
fastScrollTimeoutRef.current = window.setTimeout(() => {
|
||||
setDynamicOverscan((current) => (current !== overscan ? overscan : current));
|
||||
}, 160);
|
||||
}
|
||||
}
|
||||
lastScrollTopRef.current = container.scrollTop;
|
||||
lastScrollTimeRef.current = now;
|
||||
}
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
// Trigger infinite scroll pagination
|
||||
timeoutId = window.setTimeout(() => {
|
||||
const loaderContainer = tableContainerRef.current;
|
||||
if (!loaderContainer || !fetchNextPage || !hasNextPage || isFetchingNextPage) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = loaderContainer;
|
||||
if (scrollTop + clientHeight >= scrollHeight - 200) {
|
||||
fetchNextPage().finally();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
}, [
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
overscan,
|
||||
fastOverscanMultiplier,
|
||||
virtualizationActive,
|
||||
dynamicOverscan,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = tableContainerRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
scrollElement.removeEventListener('scroll', handleScroll);
|
||||
cleanupTimers();
|
||||
};
|
||||
}, [handleScroll, cleanupTimers]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex w-full flex-col overflow-hidden rounded-lg border border-border-light bg-background',
|
||||
'h-[calc(100vh-8rem)] max-h-[80vh]',
|
||||
className,
|
||||
)}
|
||||
role="region"
|
||||
aria-label={localize('com_ui_data_table')}
|
||||
>
|
||||
<div className="flex w-full shrink-0 items-center gap-2 border-b border-border-light md:gap-3">
|
||||
{shouldShowSearch && <DataTableSearch value={searchTerm} onChange={setSearchTerm} />}
|
||||
{customActionsRenderer &&
|
||||
customActionsRenderer({
|
||||
selectedCount,
|
||||
selectedRows,
|
||||
table: table as unknown as TTable<ProcessedDataRow<TData>>,
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className="overflow-anchor-none relative min-h-0 flex-1 overflow-auto will-change-scroll"
|
||||
style={
|
||||
{
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
overscrollBehavior: 'contain',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
role="region"
|
||||
aria-label={localize('com_ui_data_table_scroll_area')}
|
||||
aria-describedby={showSkeletons ? 'loading-status' : undefined}
|
||||
>
|
||||
<Table
|
||||
role="table"
|
||||
aria-label={localize('com_ui_data_table')}
|
||||
aria-rowcount={data.length}
|
||||
className="table-auto"
|
||||
unwrapped={true}
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-surface-secondary">
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const isDesktopOnly =
|
||||
(header.column.columnDef.meta as { desktopOnly?: boolean } | undefined)
|
||||
?.desktopOnly ?? false;
|
||||
|
||||
if (!header.column.getIsVisible()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSelectHeader = header.id === 'select';
|
||||
const meta = header.column.columnDef.meta as { className?: string } | undefined;
|
||||
const canSort = header.column.getCanSort();
|
||||
|
||||
let sortAriaLabel: string | undefined;
|
||||
if (canSort) {
|
||||
const sortState = header.column.getIsSorted();
|
||||
let sortStateLabel = 'sortable';
|
||||
if (sortState === 'asc') {
|
||||
sortStateLabel = 'ascending';
|
||||
} else if (sortState === 'desc') {
|
||||
sortStateLabel = 'descending';
|
||||
}
|
||||
|
||||
const headerLabel =
|
||||
typeof header.column.columnDef.header === 'string'
|
||||
? header.column.columnDef.header
|
||||
: header.column.id;
|
||||
|
||||
sortAriaLabel = `${headerLabel ?? ''} column, ${sortStateLabel}`;
|
||||
}
|
||||
|
||||
const handleSortingKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (canSort && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
header.column.toggleSorting();
|
||||
}
|
||||
};
|
||||
|
||||
const metaWidth = (header.column.columnDef.meta as { width?: number } | undefined)
|
||||
?.width;
|
||||
const widthStyle = isSelectHeader
|
||||
? { width: '32px', maxWidth: '32px', minWidth: '32px' }
|
||||
: metaWidth && metaWidth >= 1 && metaWidth <= 100
|
||||
? {
|
||||
width: `${metaWidth}%`,
|
||||
maxWidth: `${metaWidth}%`,
|
||||
minWidth: `${metaWidth}%`,
|
||||
}
|
||||
: {};
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'border-b border-border-light px-2 py-2 md:px-3 md:py-2',
|
||||
isSelectHeader && 'px-0 text-center',
|
||||
canSort && 'cursor-pointer hover:bg-surface-tertiary',
|
||||
meta?.className,
|
||||
header.column.getIsResizing() && 'bg-surface-tertiary/60',
|
||||
isDesktopOnly && 'hidden md:table-cell',
|
||||
)}
|
||||
style={widthStyle}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
onKeyDown={handleSortingKeyDown}
|
||||
role={canSort ? 'button' : undefined}
|
||||
tabIndex={canSort ? 0 : undefined}
|
||||
aria-label={sortAriaLabel}
|
||||
aria-sort={
|
||||
header.column.getIsSorted() as
|
||||
| 'ascending'
|
||||
| 'descending'
|
||||
| 'none'
|
||||
| undefined
|
||||
}
|
||||
>
|
||||
{isSelectHeader ? (
|
||||
flexRender(header.column.columnDef.header, header.getContext())
|
||||
) : (
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{canSort && (
|
||||
<span className="text-text-primary" aria-hidden="true">
|
||||
{{
|
||||
asc: <ArrowUp className="size-4 text-text-primary" />,
|
||||
desc: <ArrowDown className="size-4 text-text-primary" />,
|
||||
}[header.column.getIsSorted() as string] ?? (
|
||||
<ArrowDownUp className="size-4 text-text-primary" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{tableBodyContent}
|
||||
{isFetchingNextPage && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={tableColumns.length}
|
||||
className="p-4 text-center"
|
||||
id="loading-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="sr-only">{localize('com_ui_loading_more_data')}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!isLoading && !showSkeletons && rows.length === 0 && (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-12"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Label className="text-center text-text-secondary">
|
||||
{searchTerm ? localize('com_ui_no_search_results') : localize('com_ui_no_data')}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DataTable;
|
||||
@@ -1,116 +0,0 @@
|
||||
import type { ColumnDef, SortingState, Table } from '@tanstack/react-table';
|
||||
import type React from 'react';
|
||||
|
||||
export type ProcessedDataRow<TData> = TData & { _id: string; _index: number };
|
||||
|
||||
export type TableColumnDef<TData, TValue> = ColumnDef<ProcessedDataRow<TData>, TValue>;
|
||||
|
||||
export type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||
accessorKey?: string | number;
|
||||
meta?: {
|
||||
/** Column width as a percentage (1-100). Used for proportional column sizing. */
|
||||
width?: number;
|
||||
/** Additional CSS classes to apply to the column cells and header. */
|
||||
className?: string;
|
||||
/**
|
||||
* When true, this column will be hidden on mobile devices (viewport < 768px).
|
||||
* This is useful for hiding less critical information on smaller screens.
|
||||
*
|
||||
* **Usage Example:**
|
||||
* ```typescript
|
||||
* {
|
||||
* accessorKey: 'createdAt',
|
||||
* header: 'Date Created',
|
||||
* cell: ({ row }) => formatDate(row.original.createdAt),
|
||||
* meta: {
|
||||
* desktopOnly: true, // Hide this column on mobile
|
||||
* width: 20,
|
||||
* className: 'min-w-[6rem]'
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* The column will be completely hidden including:
|
||||
* - Header cell
|
||||
* - Data cells
|
||||
* - Skeleton loading cells
|
||||
*/
|
||||
desktopOnly?: boolean;
|
||||
/**
|
||||
* When true, this column's cells will use `<th scope="row">` instead of `<td>`.
|
||||
* This is important for accessibility as it marks the cell as a row header,
|
||||
* providing context for screen readers about what each row represents.
|
||||
*
|
||||
* Typically the first column (e.g., name, title) should be marked as a row header.
|
||||
*
|
||||
* **Usage Example:**
|
||||
* ```typescript
|
||||
* {
|
||||
* accessorKey: 'title',
|
||||
* header: 'Conversation Name',
|
||||
* cell: ({ row }) => row.original.title,
|
||||
* meta: {
|
||||
* isRowHeader: true // Mark this column as row headers
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
isRowHeader?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export interface DataTableConfig {
|
||||
selection?: {
|
||||
enableRowSelection?: boolean;
|
||||
showCheckboxes?: boolean;
|
||||
};
|
||||
search?: {
|
||||
enableSearch?: boolean;
|
||||
debounce?: number;
|
||||
filterColumn?: string;
|
||||
};
|
||||
skeleton?: {
|
||||
count?: number;
|
||||
};
|
||||
virtualization?: {
|
||||
overscan?: number;
|
||||
minRows?: number;
|
||||
rowHeight?: number;
|
||||
fastOverscanMultiplier?: number;
|
||||
};
|
||||
pinning?: {
|
||||
enableColumnPinning?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DataTableProps<TData extends Record<string, unknown>, TValue> {
|
||||
columns: TableColumn<TData, TValue>[];
|
||||
data: TData[];
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
isFetching?: boolean;
|
||||
config?: DataTableConfig;
|
||||
onDelete?: (selectedRows: TData[]) => Promise<void>;
|
||||
filterValue?: string;
|
||||
onFilterChange?: (value: string) => void;
|
||||
defaultSort?: SortingState;
|
||||
isFetchingNextPage?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
fetchNextPage?: () => Promise<unknown>;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: (updater: SortingState | ((old: SortingState) => SortingState)) => void;
|
||||
conversationIndex?: number;
|
||||
customActionsRenderer?: (params: {
|
||||
selectedCount: number;
|
||||
selectedRows: TData[];
|
||||
table: Table<ProcessedDataRow<TData>>;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface DataTableSearchProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import React, { memo, forwardRef } from 'react';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
import type { TableColumn } from './DataTable.types';
|
||||
import type { Row } from '@tanstack/react-table';
|
||||
import { TableCell, TableRow, TableRowHeader } from '../Table';
|
||||
import { Checkbox } from '../Checkbox';
|
||||
import { Skeleton } from '../Skeleton';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export const SelectionCheckbox = memo(
|
||||
({
|
||||
checked,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
ariaLabel: string;
|
||||
}) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(!checked);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex h-full w-8 items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(!checked);
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
SelectionCheckbox.displayName = 'SelectionCheckbox';
|
||||
|
||||
interface TableRowComponentProps<TData extends Record<string, unknown>> {
|
||||
row: Row<TData>;
|
||||
virtualIndex?: number;
|
||||
style?: React.CSSProperties;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
// ...existing code...
|
||||
const TableRowComponent = <TData extends Record<string, unknown>>(
|
||||
{ row, virtualIndex, style, selected }: TableRowComponentProps<TData>,
|
||||
ref: React.Ref<HTMLTableRowElement>,
|
||||
) => {
|
||||
// Check if we're on mobile - use window.innerWidth for component-level check
|
||||
const isSmallScreen = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
ref={ref}
|
||||
data-state={selected ? 'selected' : undefined}
|
||||
data-index={virtualIndex}
|
||||
className="border-none hover:bg-surface-secondary"
|
||||
style={style}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const meta = cell.column.columnDef.meta as
|
||||
| { className?: string; desktopOnly?: boolean; width?: number; isRowHeader?: boolean }
|
||||
| undefined;
|
||||
const isDesktopOnly = meta?.desktopOnly;
|
||||
const isRowHeader = meta?.isRowHeader;
|
||||
const percent = meta?.width;
|
||||
const widthStyle =
|
||||
cell.column.id === 'select'
|
||||
? { width: '32px', maxWidth: '32px', minWidth: '32px' }
|
||||
: percent
|
||||
? {
|
||||
width: `${percent}%`,
|
||||
maxWidth: `${percent}%`,
|
||||
minWidth: `${percent}%`, // Don't shrink on mobile
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const CellComponent = isRowHeader ? TableRowHeader : TableCell;
|
||||
|
||||
// For desktop-only columns on mobile, keep them in DOM but visually hidden
|
||||
// This ensures screen readers can still access the content
|
||||
const cellProps =
|
||||
isDesktopOnly && isSmallScreen
|
||||
? { 'aria-hidden': false as const } // Keep accessible to screen readers
|
||||
: {};
|
||||
|
||||
return (
|
||||
<CellComponent
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
'max-w-0 truncate px-2 py-2 md:px-3 md:py-3',
|
||||
cell.column.id === 'select' && 'w-8 p-1',
|
||||
meta?.className,
|
||||
isDesktopOnly && 'hidden md:table-cell',
|
||||
)}
|
||||
style={widthStyle}
|
||||
{...cellProps}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</CellComponent>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
// ...existing code...
|
||||
|
||||
type ForwardTableRowComponentType = <TData extends Record<string, unknown>>(
|
||||
props: TableRowComponentProps<TData> & React.RefAttributes<HTMLTableRowElement>,
|
||||
) => JSX.Element;
|
||||
|
||||
const ForwardTableRowComponent = forwardRef(TableRowComponent) as ForwardTableRowComponentType;
|
||||
|
||||
interface GenericRowProps {
|
||||
row: Row<Record<string, unknown>>;
|
||||
virtualIndex?: number;
|
||||
style?: React.CSSProperties;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export const MemoizedTableRow = memo(
|
||||
ForwardTableRowComponent as (props: GenericRowProps) => JSX.Element,
|
||||
(prev: GenericRowProps, next: GenericRowProps) =>
|
||||
prev.row.original === next.row.original && prev.selected === next.selected,
|
||||
);
|
||||
|
||||
export const SkeletonRows = memo(
|
||||
<TData extends Record<string, unknown>, TValue>({
|
||||
count = 10,
|
||||
columns,
|
||||
}: {
|
||||
count?: number;
|
||||
columns: TableColumn<TData, TValue>[];
|
||||
}) => (
|
||||
<>
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<TableRow key={`skeleton-${index}`} className="h-[56px] border-b border-border-light">
|
||||
{columns.map((column) => {
|
||||
const columnKey = String(
|
||||
column.id ?? ('accessorKey' in column && column.accessorKey) ?? '',
|
||||
);
|
||||
const meta = column.meta as { className?: string; desktopOnly?: boolean } | undefined;
|
||||
return (
|
||||
<TableCell
|
||||
key={columnKey}
|
||||
className={cn(
|
||||
'px-2 py-2 md:px-3',
|
||||
meta?.className,
|
||||
meta?.desktopOnly && 'hidden md:table-cell',
|
||||
)}
|
||||
>
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
);
|
||||
|
||||
SkeletonRows.displayName = 'SkeletonRows';
|
||||
@@ -1,122 +0,0 @@
|
||||
import { Component, ErrorInfo, ReactNode, createRef } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Button } from '../Button';
|
||||
import { logger } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
/**
|
||||
* Error boundary specifically for DataTable component.
|
||||
* Catches JavaScript errors in the table rendering and provides a fallback UI.
|
||||
* Handles errors from virtualizer, cell renderers, fetch operations, and child components.
|
||||
*/
|
||||
interface DataTableErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
interface DataTableErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
onError?: (error: Error) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
interface DataTableErrorBoundaryInnerProps extends DataTableErrorBoundaryProps {
|
||||
localize: ReturnType<typeof useLocalize>;
|
||||
}
|
||||
|
||||
class DataTableErrorBoundaryInner extends Component<
|
||||
DataTableErrorBoundaryInnerProps,
|
||||
DataTableErrorBoundaryState
|
||||
> {
|
||||
private errorCardRef = createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props: DataTableErrorBoundaryInnerProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): DataTableErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
logger.error('DataTable Error Boundary caught an error:', error, errorInfo);
|
||||
this.props.onError?.(error);
|
||||
}
|
||||
|
||||
componentDidUpdate(
|
||||
_prevProps: DataTableErrorBoundaryInnerProps,
|
||||
prevState: DataTableErrorBoundaryState,
|
||||
) {
|
||||
if (!prevState.hasError && this.state.hasError && this.errorCardRef.current) {
|
||||
this.errorCardRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the error state and attempt to re-render the children.
|
||||
* This can be used to retry after a table error (e.g., network retry).
|
||||
*/
|
||||
private handleReset = () => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
this.props.onReset?.();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center p-8">
|
||||
<div
|
||||
ref={this.errorCardRef}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-labelledby="datatable-error-title"
|
||||
aria-describedby="datatable-error-desc"
|
||||
tabIndex={-1}
|
||||
className="before:bg-surface-destructive/80 relative w-full max-w-md overflow-hidden rounded-lg border border-border-light bg-surface-primary-alt p-6 shadow-sm outline-none before:absolute before:left-0 before:top-0 before:h-full before:w-1 focus:ring-2 focus:ring-ring focus:ring-offset-2 dark:border-border-medium dark:bg-surface-secondary"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 text-surface-destructive" />
|
||||
<h3 id="datatable-error-title" className="text-sm font-medium text-text-primary">
|
||||
{this.props.localize('com_ui_table_error')}
|
||||
</h3>
|
||||
</div>
|
||||
<p id="datatable-error-desc" className="mt-2 text-sm text-text-secondary">
|
||||
{this.props.localize('com_ui_table_error_description')}
|
||||
</p>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={this.handleReset}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-surface-hover dark:hover:bg-surface-active"
|
||||
aria-label="Retry loading table"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
{this.props.localize('com_ui_retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{import.meta.env.MODE === 'development' && this.state.error && (
|
||||
<details className="mt-4 max-w-md rounded-md bg-surface-secondary p-3 text-xs dark:bg-surface-tertiary">
|
||||
<summary className="cursor-pointer font-medium text-text-primary">
|
||||
{this.props.localize('com_ui_error_details')}
|
||||
</summary>
|
||||
<pre className="mt-2 whitespace-pre-wrap text-text-secondary">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function DataTableErrorBoundary(props: DataTableErrorBoundaryProps) {
|
||||
const localize = useLocalize();
|
||||
return <DataTableErrorBoundaryInner {...props} localize={localize} />;
|
||||
}
|
||||
|
||||
export default DataTableErrorBoundary;
|
||||
@@ -1,37 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { startTransition } from 'react';
|
||||
import type { DataTableSearchProps } from './DataTable.types';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Input } from '../Input';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export const DataTableSearch = memo(
|
||||
({ value, onChange, placeholder, className, disabled = false }: DataTableSearchProps) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className="relative flex-1">
|
||||
<label htmlFor="table-search" className="sr-only">
|
||||
{localize('com_ui_search_table')}
|
||||
</label>
|
||||
<Input
|
||||
id="table-search"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
startTransition(() => onChange(e.target.value));
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={localize('com_ui_search_table')}
|
||||
aria-describedby="search-description"
|
||||
placeholder={placeholder || localize('com_ui_search')}
|
||||
className={cn('h-10 rounded-b-none border-0 bg-surface-secondary md:h-12', className)}
|
||||
/>
|
||||
<span id="search-description" className="sr-only">
|
||||
{localize('com_ui_search_table_description')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DataTableSearch.displayName = 'DataTableSearch';
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as DataTable } from './DataTable';
|
||||
export * from './DataTable.types';
|
||||
// Removed legacy DataTableSettings exports (store/context) as column resizing & dynamic sizing were deprecated.
|
||||
60
packages/client/src/components/DataTableColumnHeader.tsx
Normal file
60
packages/client/src/components/DataTableColumnHeader.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Column } from '@tanstack/react-table';
|
||||
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './DropdownMenu';
|
||||
import { Button } from './Button';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className = '',
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="-ml-3 h-8 data-[state=open]:bg-accent">
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDownIcon className="ml-2 h-4 w-4" />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUpIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="z-[1001]">
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectArrow,
|
||||
@@ -61,7 +61,6 @@ export default function MultiSelect<T extends string>({
|
||||
renderItemContent,
|
||||
}: MultiSelectProps<T>) {
|
||||
const selectRef = useRef<HTMLButtonElement>(null);
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
const handleValueChange = (values: T[]) => {
|
||||
setSelectedValues(values);
|
||||
@@ -72,12 +71,7 @@ export default function MultiSelect<T extends string>({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<SelectProvider
|
||||
value={selectedValues}
|
||||
setValue={handleValueChange}
|
||||
open={isPopoverOpen}
|
||||
setOpen={setIsPopoverOpen}
|
||||
>
|
||||
<SelectProvider value={selectedValues} setValue={handleValueChange}>
|
||||
{label && (
|
||||
<SelectLabel className={cn('mb-1 block text-sm text-text-primary', labelClassName)}>
|
||||
{label}
|
||||
@@ -98,12 +92,7 @@ export default function MultiSelect<T extends string>({
|
||||
<span className="mr-auto hidden truncate md:block">
|
||||
{renderSelectedValues(selectedValues, placeholder)}
|
||||
</span>
|
||||
<SelectArrow
|
||||
className={cn(
|
||||
'ml-1 hidden stroke-1 text-base opacity-75 transition-transform duration-300 md:block',
|
||||
isPopoverOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
<SelectArrow className="ml-1 hidden stroke-1 text-base opacity-75 md:block" />
|
||||
</Select>
|
||||
<SelectPopover
|
||||
gutter={4}
|
||||
|
||||
@@ -81,6 +81,7 @@ const DialogContent = React.forwardRef<
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-ring-primary ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
{/* eslint-disable-next-line i18next/no-literal-string */}
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface TableProps extends React.HTMLAttributes<HTMLTableElement> {
|
||||
unwrapped?: boolean;
|
||||
}
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, TableProps>(
|
||||
({ className, unwrapped = false, ...props }, ref) => {
|
||||
const tableElement = (
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
);
|
||||
|
||||
if (unwrapped) {
|
||||
return tableElement;
|
||||
}
|
||||
|
||||
return <div className="relative w-full overflow-auto">{tableElement}</div>;
|
||||
},
|
||||
</div>
|
||||
),
|
||||
);
|
||||
Table.displayName = 'Table';
|
||||
|
||||
@@ -89,22 +79,6 @@ const TableCell = React.forwardRef<
|
||||
));
|
||||
TableCell.displayName = 'TableCell';
|
||||
|
||||
const TableRowHeader = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
scope="row"
|
||||
className={cn(
|
||||
'p-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRowHeader.displayName = 'TableRowHeader';
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
@@ -113,14 +87,4 @@ const TableCaption = React.forwardRef<
|
||||
));
|
||||
TableCaption.displayName = 'TableCaption';
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableRowHeader,
|
||||
TableCaption,
|
||||
};
|
||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './AlertDialog';
|
||||
export * from './Breadcrumb';
|
||||
export * from './Button';
|
||||
export * from './Checkbox';
|
||||
export * from './DataTableColumnHeader';
|
||||
export * from './Dialog';
|
||||
export * from './DropdownMenu';
|
||||
export * from './HoverCard';
|
||||
@@ -30,13 +31,13 @@ export * from './InputOTP';
|
||||
export * from './MultiSearch';
|
||||
export * from './Resizable';
|
||||
export * from './Select';
|
||||
export * from './DataTable/index';
|
||||
export { default as Radio } from './Radio';
|
||||
export { default as Badge } from './Badge';
|
||||
export { default as Avatar } from './Avatar';
|
||||
export { default as Combobox } from './Combobox';
|
||||
export { default as Dropdown } from './Dropdown';
|
||||
export { default as SplitText } from './SplitText';
|
||||
export { default as DataTable } from './DataTable';
|
||||
export { default as FormInput } from './FormInput';
|
||||
export { default as PixelCard } from './PixelCard';
|
||||
export { default as FileUpload } from './FileUpload';
|
||||
|
||||
@@ -1,27 +1,4 @@
|
||||
{
|
||||
"com_ui_cancel": "Cancel",
|
||||
"com_ui_no_options": "No options available",
|
||||
"com_ui_no_results_found": "No results found",
|
||||
"com_ui_no_data_available": "No data available",
|
||||
"com_ui_select_all": "Select All",
|
||||
"com_ui_no_selection": "No selection",
|
||||
"com_ui_confirm_bulk_delete": "Are you sure you want to delete the selected items? This action cannot be undone.",
|
||||
"com_ui_delete_success": "Items deleted successfully",
|
||||
"com_ui_retry": "Retry",
|
||||
"com_ui_selected_count": "{count} selected",
|
||||
"com_ui_data_table": "Data Table",
|
||||
"com_ui_no_data": "No data",
|
||||
"com_ui_delete_selected": "Delete Selected",
|
||||
"com_ui_search_table": "Search table",
|
||||
"com_ui_search_table_description": "Type to filter results",
|
||||
"com_ui_search": "Search",
|
||||
"com_ui_data_table_scroll_area": "Scrollable data table area",
|
||||
"com_ui_select_row": "Select row {{0}}",
|
||||
"com_ui_loading_more_data": "Loading more data...",
|
||||
"com_ui_no_search_results": "No search results found",
|
||||
"com_ui_table_error": "Table Error",
|
||||
"com_ui_table_error_description": "Table failed to load. Please refresh or try again.",
|
||||
"com_ui_error_details": "Error Details (Dev)",
|
||||
"com_ui_enabled": "Enabled",
|
||||
"com_ui_disabled": "Disabled"
|
||||
"com_ui_no_options": "No options available"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './utils';
|
||||
export * from './theme';
|
||||
export { default as logger } from './logger';
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
const isLoggerEnabled = process.env.VITE_ENABLE_LOGGER === 'true';
|
||||
const loggerFilter = process.env.VITE_LOGGER_FILTER || '';
|
||||
|
||||
type LogFunction = (...args: unknown[]) => void;
|
||||
|
||||
const createLogFunction = (
|
||||
consoleMethod: LogFunction,
|
||||
type?: 'log' | 'warn' | 'error' | 'info' | 'debug' | 'dir',
|
||||
): LogFunction => {
|
||||
return (...args: unknown[]) => {
|
||||
if (isDevelopment || isLoggerEnabled) {
|
||||
const tag = typeof args[0] === 'string' ? args[0] : '';
|
||||
if (shouldLog(tag)) {
|
||||
if (tag && typeof args[1] === 'string' && type === 'error') {
|
||||
consoleMethod(`[${tag}] ${args[1]}`, ...args.slice(2));
|
||||
} else if (tag && args.length > 1) {
|
||||
consoleMethod(`[${tag}]`, ...args.slice(1));
|
||||
} else {
|
||||
consoleMethod(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const logger = {
|
||||
log: createLogFunction(console.log, 'log'),
|
||||
dir: createLogFunction(console.dir, 'dir'),
|
||||
warn: createLogFunction(console.warn, 'warn'),
|
||||
info: createLogFunction(console.info, 'info'),
|
||||
error: createLogFunction(console.error, 'error'),
|
||||
debug: createLogFunction(console.debug, 'debug'),
|
||||
};
|
||||
|
||||
function shouldLog(tag: string): boolean {
|
||||
if (!loggerFilter) {
|
||||
return true;
|
||||
}
|
||||
/* If no tag is provided, always log */
|
||||
if (!tag) {
|
||||
return true;
|
||||
}
|
||||
return loggerFilter
|
||||
.split(',')
|
||||
.some((filter) => tag.toLowerCase().includes(filter.trim().toLowerCase()));
|
||||
}
|
||||
|
||||
export default logger;
|
||||
114
packages/data-provider/SPECIAL_VARIABLES.md
Normal file
114
packages/data-provider/SPECIAL_VARIABLES.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Special Variables in LibreChat
|
||||
|
||||
LibreChat supports special variables that can be used in agent prompts, conversation prompts, and other text fields. These variables are automatically replaced with their corresponding values at runtime.
|
||||
|
||||
## Available Special Variables
|
||||
|
||||
### Date and Time Variables
|
||||
|
||||
#### UTC-based Variables
|
||||
- **`{{current_date}}`**: Current date in UTC timezone
|
||||
- Format: `YYYY-MM-DD (D)` where D is the day of week (0=Sunday, 1=Monday, etc.)
|
||||
- Example: `2024-04-29 (1)` (Monday, April 29, 2024)
|
||||
|
||||
- **`{{current_datetime}}`**: Current date and time in UTC timezone
|
||||
- Format: `YYYY-MM-DD HH:mm:ss (D)` where D is the day of week
|
||||
- Example: `2024-04-29 12:34:56 (1)` (Monday, April 29, 2024 at 12:34:56 PM UTC)
|
||||
|
||||
- **`{{iso_datetime}}`**: Current date and time in ISO 8601 format (UTC)
|
||||
- Format: ISO 8601 standard
|
||||
- Example: `2024-04-29T16:34:56.000Z`
|
||||
|
||||
#### Local Timezone Variables
|
||||
- **`{{local_date}}`**: Current date in the user's local timezone
|
||||
- Format: `YYYY-MM-DD (D)` where D is the day of week
|
||||
- Example: `2024-04-29 (1)` (Monday, April 29, 2024 in user's timezone)
|
||||
- Note: Falls back to UTC if timezone is not available
|
||||
|
||||
- **`{{local_datetime}}`**: Current date and time in the user's local timezone
|
||||
- Format: `YYYY-MM-DD HH:mm:ss (D)` where D is the day of week
|
||||
- Example: `2024-04-29 08:34:56 (1)` (Monday, April 29, 2024 at 8:34:56 AM in America/New_York)
|
||||
- Note: Falls back to UTC if timezone is not available
|
||||
|
||||
### User Variables
|
||||
- **`{{current_user}}`**: Name of the current user
|
||||
- Example: `John Doe`
|
||||
- Note: Only replaced if user information is available
|
||||
|
||||
## Day of Week Reference
|
||||
- `0` = Sunday
|
||||
- `1` = Monday
|
||||
- `2` = Tuesday
|
||||
- `3` = Wednesday
|
||||
- `4` = Thursday
|
||||
- `5` = Friday
|
||||
- `6` = Saturday
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Agent Instructions
|
||||
```
|
||||
You are an AI assistant helping {{current_user}}.
|
||||
Today's date is {{local_date}}.
|
||||
Current time in user's timezone: {{local_datetime}}
|
||||
UTC time: {{current_datetime}}
|
||||
```
|
||||
|
||||
### System Prompts
|
||||
```
|
||||
System time (UTC): {{current_datetime}}
|
||||
User's local time: {{local_datetime}}
|
||||
Remember to consider the user's timezone when scheduling or discussing times.
|
||||
```
|
||||
|
||||
### Conversation Starters
|
||||
```
|
||||
Good morning {{current_user}}! Today is {{local_date}}.
|
||||
How can I help you today?
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Timezone Detection
|
||||
The user's timezone is automatically detected from their browser using the JavaScript `Intl.DateTimeFormat().resolvedOptions().timeZone` API. This provides an IANA timezone identifier (e.g., `America/New_York`, `Europe/London`, `Asia/Tokyo`).
|
||||
|
||||
### Fallback Behavior
|
||||
If the timezone cannot be detected or is invalid:
|
||||
- `{{local_date}}` falls back to `{{current_date}}` (UTC)
|
||||
- `{{local_datetime}}` falls back to `{{current_datetime}}` (UTC)
|
||||
|
||||
### Case Insensitivity
|
||||
All special variables are case-insensitive. The following are all equivalent:
|
||||
- `{{current_date}}` = `{{Current_Date}}` = `{{CURRENT_DATE}}`
|
||||
- `{{local_datetime}}` = `{{Local_DateTime}}` = `{{LOCAL_DATETIME}}`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
For developers working with special variables:
|
||||
|
||||
1. **Server-side processing**: The `replaceSpecialVars` function in `packages/data-provider/src/parsers.ts` handles all replacements.
|
||||
|
||||
2. **Timezone propagation**: The timezone is automatically included in API requests from the client.
|
||||
|
||||
3. **Testing**: Comprehensive tests are available in `packages/data-provider/specs/parsers.spec.ts`.
|
||||
|
||||
4. **Dependencies**: The implementation uses `dayjs` with `timezone` and `utc` plugins for timezone conversions.
|
||||
|
||||
## Migration Guide
|
||||
|
||||
If you were using the old UTC-only variables in your prompts, they continue to work exactly as before. The new local timezone variables are additive and don't break existing functionality.
|
||||
|
||||
### Upgrading Existing Prompts
|
||||
```diff
|
||||
- Current time (UTC): {{current_datetime}}
|
||||
+ Current time (UTC): {{current_datetime}}
|
||||
+ Current time (Your timezone): {{local_datetime}}
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential future additions:
|
||||
- Custom date/time formats
|
||||
- Relative time expressions (e.g., "tomorrow", "next week")
|
||||
- User-specified timezone overrides
|
||||
- Additional locale-aware formatting options
|
||||
@@ -5,7 +5,7 @@ import type { TUser } from '../src/types';
|
||||
// Mock dayjs module with consistent date/time values regardless of environment
|
||||
jest.mock('dayjs', () => {
|
||||
// Create a mock implementation that returns fixed values
|
||||
const mockDayjs = () => ({
|
||||
const mockDayjs = (input?: any) => ({
|
||||
format: (format: string) => {
|
||||
if (format === 'YYYY-MM-DD') {
|
||||
return '2024-04-29';
|
||||
@@ -17,6 +17,33 @@ jest.mock('dayjs', () => {
|
||||
},
|
||||
day: () => 1, // 1 = Monday
|
||||
toISOString: () => '2024-04-29T16:34:56.000Z',
|
||||
tz: (timezone: string) => ({
|
||||
format: (format: string) => {
|
||||
// Mock timezone-specific formatting for America/New_York (UTC-4 during DST)
|
||||
if (timezone === 'America/New_York') {
|
||||
if (format === 'YYYY-MM-DD') {
|
||||
return '2024-04-29';
|
||||
}
|
||||
if (format === 'YYYY-MM-DD HH:mm:ss') {
|
||||
return '2024-04-29 08:34:56'; // 4 hours behind UTC 12:34:56
|
||||
}
|
||||
}
|
||||
// Mock timezone-specific formatting for Asia/Tokyo (UTC+9)
|
||||
if (timezone === 'Asia/Tokyo') {
|
||||
if (format === 'YYYY-MM-DD') {
|
||||
return '2024-04-29';
|
||||
}
|
||||
if (format === 'YYYY-MM-DD HH:mm:ss') {
|
||||
return '2024-04-29 21:34:56'; // 9 hours ahead of UTC 12:34:56
|
||||
}
|
||||
}
|
||||
return format;
|
||||
},
|
||||
day: () => {
|
||||
// Return same day number for simplicity in tests
|
||||
return 1;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Add any static methods needed
|
||||
@@ -121,5 +148,55 @@ describe('replaceSpecialVars', () => {
|
||||
expect(result).toContain('2024-04-29 12:34:56 (1)'); // current_datetime
|
||||
expect(result).toContain('2024-04-29T16:34:56.000Z'); // iso_datetime
|
||||
expect(result).toContain('Test User'); // current_user
|
||||
// local_date and local_datetime should fall back to UTC when no timezone provided
|
||||
expect(result).toContain('2024-04-29 (1)'); // local_date (fallback to UTC)
|
||||
expect(result).toContain('2024-04-29 12:34:56 (1)'); // local_datetime (fallback to UTC)
|
||||
});
|
||||
|
||||
test('should replace {{local_date}} with the timezone-aware date when timezone is provided', () => {
|
||||
const result = replaceSpecialVars({
|
||||
text: 'Today in NY is {{local_date}}',
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
expect(result).toBe('Today in NY is 2024-04-29 (1)');
|
||||
});
|
||||
|
||||
test('should replace {{local_datetime}} with the timezone-aware datetime when timezone is provided', () => {
|
||||
const result = replaceSpecialVars({
|
||||
text: 'Now in NY is {{local_datetime}}',
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
expect(result).toBe('Now in NY is 2024-04-29 08:34:56 (1)');
|
||||
});
|
||||
|
||||
test('should replace {{local_datetime}} with Tokyo timezone', () => {
|
||||
const result = replaceSpecialVars({
|
||||
text: 'Now in Tokyo is {{local_datetime}}',
|
||||
timezone: 'Asia/Tokyo',
|
||||
});
|
||||
expect(result).toBe('Now in Tokyo is 2024-04-29 21:34:56 (1)');
|
||||
});
|
||||
|
||||
test('should fall back to UTC for local variables when no timezone is provided', () => {
|
||||
const result = replaceSpecialVars({
|
||||
text: 'Date: {{local_date}}, Time: {{local_datetime}}',
|
||||
});
|
||||
expect(result).toBe('Date: 2024-04-29 (1), Time: 2024-04-29 12:34:56 (1)');
|
||||
});
|
||||
|
||||
test('should handle both UTC and local timezone variables in the same text', () => {
|
||||
const result = replaceSpecialVars({
|
||||
text: 'UTC: {{current_datetime}}, Local: {{local_datetime}}',
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
expect(result).toBe('UTC: 2024-04-29 12:34:56 (1), Local: 2024-04-29 08:34:56 (1)');
|
||||
});
|
||||
|
||||
test('should be case-insensitive for local timezone variables', () => {
|
||||
const result = replaceSpecialVars({
|
||||
text: 'Date: {{LOCAL_DATE}}, Time: {{Local_DateTime}}',
|
||||
timezone: 'America/New_York',
|
||||
});
|
||||
expect(result).toBe('Date: 2024-04-29 (1), Time: 2024-04-29 08:34:56 (1)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1723,6 +1723,8 @@ export const specialVariables = {
|
||||
current_user: true,
|
||||
iso_datetime: true,
|
||||
current_datetime: true,
|
||||
local_date: true,
|
||||
local_datetime: true,
|
||||
};
|
||||
|
||||
export type TSpecialVarLabel = `com_ui_special_var_${keyof typeof specialVariables}`;
|
||||
|
||||
@@ -38,6 +38,8 @@ export default function createPayload(submission: t.TSubmission) {
|
||||
conversationId,
|
||||
isContinued: !!(isEdited && isContinued),
|
||||
ephemeralAgent: s.isAssistantsEndpoint(endpoint) ? undefined : ephemeralAgent,
|
||||
timezone:
|
||||
typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : undefined,
|
||||
};
|
||||
|
||||
return { server, payload };
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import type { ZodIssue } from 'zod';
|
||||
import type * as a from './types/assistants';
|
||||
import type * as s from './schemas';
|
||||
import type * as t from './types';
|
||||
import { ContentTypes } from './types/runs';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
import {
|
||||
openAISchema,
|
||||
googleSchema,
|
||||
@@ -410,7 +415,46 @@ export function findLastSeparatorIndex(text: string, separators = SEPARATORS): n
|
||||
return lastIndex;
|
||||
}
|
||||
|
||||
export function replaceSpecialVars({ text, user }: { text: string; user?: t.TUser | null }) {
|
||||
/**
|
||||
* Replaces special variables in text with their corresponding values.
|
||||
*
|
||||
* Available special variables:
|
||||
* - {{current_date}}: Current date in UTC (YYYY-MM-DD with day of week)
|
||||
* - {{current_datetime}}: Current datetime in UTC (YYYY-MM-DD HH:mm:ss with day of week)
|
||||
* - {{iso_datetime}}: Current datetime in ISO 8601 format (UTC)
|
||||
* - {{local_date}}: Current date in user's local timezone (YYYY-MM-DD with day of week)
|
||||
* - {{local_datetime}}: Current datetime in user's local timezone (YYYY-MM-DD HH:mm:ss with day of week)
|
||||
* - {{current_user}}: Name of the current user (if available)
|
||||
*
|
||||
* Day of week values: 0=Sunday, 1=Monday, 2=Tuesday, 3=Wednesday, 4=Thursday, 5=Friday, 6=Saturday
|
||||
*
|
||||
* Note: {{local_date}} and {{local_datetime}} require the timezone parameter.
|
||||
* If timezone is not provided, they will fall back to UTC values.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* replaceSpecialVars({
|
||||
* text: 'Today is {{local_date}} and it\'s {{current_user}}\'s session',
|
||||
* user: { name: 'John' },
|
||||
* timezone: 'America/New_York'
|
||||
* });
|
||||
* // Result: "Today is 2024-04-29 (1) and it's John's session"
|
||||
* ```
|
||||
*
|
||||
* @param text - The text containing special variables to replace
|
||||
* @param user - Optional user object containing user information
|
||||
* @param timezone - Optional IANA timezone string (e.g., 'America/New_York', 'Europe/London', 'Asia/Tokyo')
|
||||
* @returns The text with all special variables replaced with their values
|
||||
*/
|
||||
export function replaceSpecialVars({
|
||||
text,
|
||||
user,
|
||||
timezone,
|
||||
}: {
|
||||
text: string;
|
||||
user?: t.TUser | null;
|
||||
timezone?: string;
|
||||
}) {
|
||||
let result = text;
|
||||
if (!result) {
|
||||
return result;
|
||||
@@ -428,6 +472,27 @@ export function replaceSpecialVars({ text, user }: { text: string; user?: t.TUse
|
||||
const isoDatetime = dayjs().toISOString();
|
||||
result = result.replace(/{{iso_datetime}}/gi, isoDatetime);
|
||||
|
||||
// Local timezone support
|
||||
if (timezone) {
|
||||
try {
|
||||
const localDate = dayjs().tz(timezone).format('YYYY-MM-DD');
|
||||
const localDayNumber = dayjs().tz(timezone).day();
|
||||
const localCombinedDate = `${localDate} (${localDayNumber})`;
|
||||
result = result.replace(/{{local_date}}/gi, localCombinedDate);
|
||||
|
||||
const localDatetime = dayjs().tz(timezone).format('YYYY-MM-DD HH:mm:ss');
|
||||
result = result.replace(/{{local_datetime}}/gi, `${localDatetime} (${localDayNumber})`);
|
||||
} catch {
|
||||
// If timezone is invalid, fall back to UTC values for local_* variables
|
||||
result = result.replace(/{{local_date}}/gi, combinedDate);
|
||||
result = result.replace(/{{local_datetime}}/gi, `${currentDatetime} (${dayNumber})`);
|
||||
}
|
||||
} else {
|
||||
// If no timezone is provided, replace local_* variables with UTC values
|
||||
result = result.replace(/{{local_date}}/gi, combinedDate);
|
||||
result = result.replace(/{{local_datetime}}/gi, `${currentDatetime} (${dayNumber})`);
|
||||
}
|
||||
|
||||
if (user && user.name) {
|
||||
result = result.replace(/{{current_user}}/gi, user.name);
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ export type TPayload = Partial<TMessage> &
|
||||
isTemporary: boolean;
|
||||
ephemeralAgent?: TEphemeralAgent | null;
|
||||
editedContent?: TEditedContent | null;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
export type TEditedContent =
|
||||
|
||||
@@ -5,8 +5,5 @@ import type * as t from '~/types';
|
||||
* Creates or returns the AgentCategory model using the provided mongoose instance and schema
|
||||
*/
|
||||
export function createAgentCategoryModel(mongoose: typeof import('mongoose')) {
|
||||
return (
|
||||
mongoose.models.AgentCategory ||
|
||||
mongoose.model<t.IAgentCategory>('AgentCategory', agentCategorySchema)
|
||||
);
|
||||
}
|
||||
return mongoose.models.AgentCategory || mongoose.model<t.IAgentCategory>('AgentCategory', agentCategorySchema);
|
||||
}
|
||||
Reference in New Issue
Block a user