mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 00:10:15 +01:00
📈 feat: Chat rating for feedback (#5878)
* feat: working started for feedback implementation. TODO: - needs some refactoring. - needs some UI animations. * feat: working rate functionality * feat: works now as well to reader the already rated responses from the server. * feat: added the option to give feedback in text (optional) * feat: added Dismiss option `x` to the `FeedbackTagOptions` * ✨ feat: Add rating and ratingContent fields to message schema * 🔧 chore: Bump version to 0.0.3 in package.json * ✨ feat: Enhance feedback localization and update UI elements * 🚀 feat: Implement feedback tagging system with thumbs up/down options * 🚀 feat: Add data-provider package to unused i18n keys detection * 🎨 style: update HoverButtons' style * 🎨 style: Update HoverButtons and Fork components for improved styling and visibility * 🔧 feat: Implement feedback system with rating and content options * 🔧 feat: Enhance feedback handling with improved rating toggle and tag options * 🔧 feat: Integrate toast notifications for feedback submission and clean up unused state * 🔧 feat: Remove unused feedback tag options from translation file * ✨ refactor: clean up Feedback component and improve HoverButtons structure * ✨ refactor: remove unused settings switches for auto scroll, hide side panel, and user message markdown * refactor: reorganize import order * ✨ refactor: enhance HoverButtons and Fork components with improved styles and animations * ✨ refactor: update feedback response phrases for improved user engagement * ✨ refactor: add CheckboxOption component and streamline fork options rendering * Refactor feedback components and logic - Consolidated feedback handling into a single Feedback component, removing FeedbackButtons and FeedbackTagOptions. - Introduced new feedback tagging system with detailed tags for both thumbs up and thumbs down ratings. - Updated feedback schema to include new tags and improved type definitions. - Enhanced user interface for feedback collection, including a dialog for additional comments. - Removed obsolete files and adjusted imports accordingly. - Updated translations for new feedback tags and placeholders. * ✨ refactor: update feedback handling by replacing rating fields with feedback in message updates * fix: add missing validateMessageReq middleware to feedback route and refactor feedback system * 🗑️ chore: Remove redundant fork option explanations from translation file * 🔧 refactor: Remove unused dependency from feedback callback * 🔧 refactor: Simplify message update response structure and improve error logging * Chore: removed unused tests. --------- Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com>
This commit is contained in:
parent
4808c5be48
commit
4cbab86b45
76 changed files with 1592 additions and 835 deletions
3
.github/workflows/i18n-unused-keys.yml
vendored
3
.github/workflows/i18n-unused-keys.yml
vendored
|
|
@ -5,12 +5,13 @@ on:
|
|||
paths:
|
||||
- "client/src/**"
|
||||
- "api/**"
|
||||
- "packages/data-provider/src/**"
|
||||
|
||||
jobs:
|
||||
detect-unused-i18n-keys:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write # Required for posting PR comments
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
|
|
|||
|
|
@ -244,9 +244,9 @@ class ChatGPTClient extends BaseClient {
|
|||
|
||||
baseURL = this.langchainProxy
|
||||
? constructAzureURL({
|
||||
baseURL: this.langchainProxy,
|
||||
azureOptions: this.azure,
|
||||
})
|
||||
baseURL: this.langchainProxy,
|
||||
azureOptions: this.azure,
|
||||
})
|
||||
: this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0];
|
||||
|
||||
if (this.options.forcePrompt) {
|
||||
|
|
@ -339,7 +339,6 @@ class ChatGPTClient extends BaseClient {
|
|||
opts.body = JSON.stringify(modelOptions);
|
||||
|
||||
if (modelOptions.stream) {
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
let done = false;
|
||||
|
|
|
|||
|
|
@ -236,11 +236,11 @@ class GoogleClient extends BaseClient {
|
|||
msg.content = (
|
||||
!Array.isArray(msg.content)
|
||||
? [
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
[ContentTypes.TEXT]: msg.content,
|
||||
},
|
||||
]
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
[ContentTypes.TEXT]: msg.content,
|
||||
},
|
||||
]
|
||||
: msg.content
|
||||
).concat(message.image_urls);
|
||||
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const messageHistory = [
|
|||
{
|
||||
role: 'user',
|
||||
isCreatedByUser: true,
|
||||
text: 'What\'s up',
|
||||
text: "What's up",
|
||||
messageId: '3',
|
||||
parentMessageId: '2',
|
||||
},
|
||||
|
|
@ -456,7 +456,7 @@ describe('BaseClient', () => {
|
|||
|
||||
const chatMessages2 = await TestClient.loadHistory(conversationId, '3');
|
||||
expect(TestClient.currentMessages).toHaveLength(3);
|
||||
expect(chatMessages2[chatMessages2.length - 1].text).toEqual('What\'s up');
|
||||
expect(chatMessages2[chatMessages2.length - 1].text).toEqual("What's up");
|
||||
});
|
||||
|
||||
/* Most of the new sendMessage logic revolving around edited/continued AI messages
|
||||
|
|
|
|||
|
|
@ -462,17 +462,17 @@ describe('OpenAIClient', () => {
|
|||
role: 'system',
|
||||
name: 'example_user',
|
||||
content:
|
||||
'Let\'s circle back when we have more bandwidth to touch base on opportunities for increased leverage.',
|
||||
"Let's circle back when we have more bandwidth to touch base on opportunities for increased leverage.",
|
||||
},
|
||||
{
|
||||
role: 'system',
|
||||
name: 'example_assistant',
|
||||
content: 'Let\'s talk later when we\'re less busy about how to do better.',
|
||||
content: "Let's talk later when we're less busy about how to do better.",
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'This late pivot means we don\'t have time to boil the ocean for the client deliverable.',
|
||||
"This late pivot means we don't have time to boil the ocean for the client deliverable.",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION = `Describe the changes, enhancement
|
|||
Always base this prompt on the most recently uploaded reference images.`;
|
||||
|
||||
const displayMessage =
|
||||
'The tool displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.';
|
||||
"The tool displayed an image. All generated images are already plainly visible, so don't repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.";
|
||||
|
||||
/**
|
||||
* Replaces unwanted characters from the input string
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@ async function updateMessage(req, message, metadata) {
|
|||
text: updatedMessage.text,
|
||||
isCreatedByUser: updatedMessage.isCreatedByUser,
|
||||
tokenCount: updatedMessage.tokenCount,
|
||||
feedback: updatedMessage.feedback,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error('Error updating message:', err);
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ describe('Message Operations', () => {
|
|||
});
|
||||
|
||||
describe('Conversation Hijacking Prevention', () => {
|
||||
it('should not allow editing a message in another user\'s conversation', async () => {
|
||||
it("should not allow editing a message in another user's conversation", async () => {
|
||||
const attackerReq = { user: { id: 'attacker123' } };
|
||||
const victimConversationId = 'victim-convo-123';
|
||||
const victimMessageId = 'victim-msg-123';
|
||||
|
|
@ -175,7 +175,7 @@ describe('Message Operations', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should not allow deleting messages from another user\'s conversation', async () => {
|
||||
it("should not allow deleting messages from another user's conversation", async () => {
|
||||
const attackerReq = { user: { id: 'attacker123' } };
|
||||
const victimConversationId = 'victim-convo-123';
|
||||
const victimMessageId = 'victim-msg-123';
|
||||
|
|
@ -193,7 +193,7 @@ describe('Message Operations', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should not allow inserting a new message into another user\'s conversation', async () => {
|
||||
it("should not allow inserting a new message into another user's conversation", async () => {
|
||||
const attackerReq = { user: { id: 'attacker123' } };
|
||||
const victimConversationId = uuidv4(); // Use a valid UUID
|
||||
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
|
|||
// Save user message if needed
|
||||
if (!client.skipSaveUserMessage) {
|
||||
await saveMessage(req, userMessage, {
|
||||
context: 'api/server/controllers/agents/request.js - don\'t skip saving user message',
|
||||
context: "api/server/controllers/agents/request.js - don't skip saving user message",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -327,7 +327,7 @@ const handleAbortError = async (res, req, error, data) => {
|
|||
errorText = `{"type":"${ErrorTypes.INVALID_REQUEST}"}`;
|
||||
}
|
||||
|
||||
if (error?.message?.includes('does not support \'system\'')) {
|
||||
if (error?.message?.includes("does not support 'system'")) {
|
||||
errorText = `{"type":"${ErrorTypes.NO_SYSTEM_MESSAGES}"}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -253,6 +253,31 @@ router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) =
|
|||
}
|
||||
});
|
||||
|
||||
router.put('/:conversationId/:messageId/feedback', validateMessageReq, async (req, res) => {
|
||||
try {
|
||||
const { conversationId, messageId } = req.params;
|
||||
const { feedback } = req.body;
|
||||
|
||||
const updatedMessage = await updateMessage(
|
||||
req,
|
||||
{
|
||||
messageId,
|
||||
feedback: feedback || null,
|
||||
},
|
||||
{ context: 'updateFeedback' },
|
||||
);
|
||||
|
||||
res.json({
|
||||
messageId,
|
||||
conversationId,
|
||||
feedback: updatedMessage.feedback,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating message feedback:', error);
|
||||
res.status(500).json({ error: 'Failed to update feedback' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
|
||||
try {
|
||||
const { messageId } = req.params;
|
||||
|
|
|
|||
|
|
@ -457,11 +457,20 @@ export type VoiceOption = {
|
|||
};
|
||||
|
||||
export type TMessageAudio = {
|
||||
messageId?: string;
|
||||
content?: t.TMessageContentParts[] | string;
|
||||
className?: string;
|
||||
isLast: boolean;
|
||||
isLast?: boolean;
|
||||
index: number;
|
||||
messageId: string;
|
||||
content: string;
|
||||
className?: string;
|
||||
renderButton?: (props: {
|
||||
onClick: (e?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
isActive?: boolean;
|
||||
isVisible?: boolean;
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
}) => React.ReactNode;
|
||||
};
|
||||
|
||||
export type OptionWithIcon = Option & { icon?: React.ReactNode };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable jsx-a11y/media-has-caption */
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TMessageAudio } from '~/common';
|
||||
import { useLocalize, useTTSBrowser, useTTSExternal } from '~/hooks';
|
||||
|
|
@ -7,7 +7,14 @@ import { VolumeIcon, VolumeMuteIcon, Spinner } from '~/components';
|
|||
import { logger } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export function BrowserTTS({ isLast, index, messageId, content, className }: TMessageAudio) {
|
||||
export function BrowserTTS({
|
||||
isLast,
|
||||
index,
|
||||
messageId,
|
||||
content,
|
||||
className,
|
||||
renderButton,
|
||||
}: TMessageAudio) {
|
||||
const localize = useLocalize();
|
||||
const playbackRate = useRecoilValue(store.playbackRate);
|
||||
|
||||
|
|
@ -46,21 +53,30 @@ export function BrowserTTS({ isLast, index, messageId, content, className }: TMe
|
|||
audioRef.current,
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = false;
|
||||
}
|
||||
toggleSpeech();
|
||||
};
|
||||
|
||||
const title = isSpeaking === true ? localize('com_ui_stop') : localize('com_ui_read_aloud');
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className={className}
|
||||
onClickCapture={() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = false;
|
||||
}
|
||||
toggleSpeech();
|
||||
}}
|
||||
type="button"
|
||||
title={isSpeaking === true ? localize('com_ui_stop') : localize('com_ui_read_aloud')}
|
||||
>
|
||||
{renderIcon('19')}
|
||||
</button>
|
||||
{renderButton ? (
|
||||
renderButton({
|
||||
onClick: handleClick,
|
||||
title: title,
|
||||
icon: renderIcon('19'),
|
||||
isActive: isSpeaking,
|
||||
className,
|
||||
})
|
||||
) : (
|
||||
<button className={className} onClickCapture={handleClick} type="button" title={title}>
|
||||
{renderIcon('19')}
|
||||
</button>
|
||||
)}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
controls
|
||||
|
|
|
|||
|
|
@ -102,12 +102,12 @@ export function EndpointItem({ endpoint }: EndpointItemProps) {
|
|||
if (endpoint.hasModels) {
|
||||
const filteredModels = searchValue
|
||||
? filterModels(
|
||||
endpoint,
|
||||
(endpoint.models || []).map((model) => model.name),
|
||||
searchValue,
|
||||
agentsMap,
|
||||
assistantsMap,
|
||||
)
|
||||
endpoint,
|
||||
(endpoint.models || []).map((model) => model.name),
|
||||
searchValue,
|
||||
agentsMap,
|
||||
assistantsMap,
|
||||
)
|
||||
: null;
|
||||
const placeholder =
|
||||
isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)
|
||||
|
|
|
|||
|
|
@ -45,10 +45,10 @@ export function EndpointModelItem({ modelId, endpoint, isSelected }: EndpointMod
|
|||
</div>
|
||||
) : (isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value)) &&
|
||||
endpoint.icon ? (
|
||||
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||
{endpoint.icon}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||
{endpoint.icon}
|
||||
</div>
|
||||
) : null}
|
||||
<span>{modelName}</span>
|
||||
</div>
|
||||
{isGlobal && <EarthIcon className="ml-auto size-4 text-green-400" />}
|
||||
|
|
|
|||
|
|
@ -102,22 +102,22 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP
|
|||
const filteredModels = endpoint.label.toLowerCase().includes(lowerQuery)
|
||||
? endpoint.models
|
||||
: endpoint.models.filter((model) => {
|
||||
let modelName = model.name;
|
||||
if (
|
||||
isAgentsEndpoint(endpoint.value) &&
|
||||
let modelName = model.name;
|
||||
if (
|
||||
isAgentsEndpoint(endpoint.value) &&
|
||||
endpoint.agentNames &&
|
||||
endpoint.agentNames[model.name]
|
||||
) {
|
||||
modelName = endpoint.agentNames[model.name];
|
||||
} else if (
|
||||
isAssistantsEndpoint(endpoint.value) &&
|
||||
) {
|
||||
modelName = endpoint.agentNames[model.name];
|
||||
} else if (
|
||||
isAssistantsEndpoint(endpoint.value) &&
|
||||
endpoint.assistantNames &&
|
||||
endpoint.assistantNames[model.name]
|
||||
) {
|
||||
modelName = endpoint.assistantNames[model.name];
|
||||
}
|
||||
return modelName.toLowerCase().includes(lowerQuery);
|
||||
});
|
||||
) {
|
||||
modelName = endpoint.assistantNames[model.name];
|
||||
}
|
||||
return modelName.toLowerCase().includes(lowerQuery);
|
||||
});
|
||||
|
||||
if (!filteredModels.length) {
|
||||
return null; // skip if no models match
|
||||
|
|
|
|||
347
client/src/components/Chat/Messages/Feedback.tsx
Normal file
347
client/src/components/Chat/Messages/Feedback.tsx
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { TFeedback, TFeedbackTag, getTagsForRating } from 'librechat-data-provider';
|
||||
import {
|
||||
AlertCircle,
|
||||
PenTool,
|
||||
ImageOff,
|
||||
Ban,
|
||||
HelpCircle,
|
||||
CheckCircle,
|
||||
Lightbulb,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogContent,
|
||||
OGDialogTitle,
|
||||
ThumbUpIcon,
|
||||
ThumbDownIcon,
|
||||
} from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface FeedbackProps {
|
||||
handleFeedback: ({ feedback }: { feedback: TFeedback | undefined }) => void;
|
||||
feedback?: TFeedback;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
AlertCircle,
|
||||
PenTool,
|
||||
ImageOff,
|
||||
Ban,
|
||||
HelpCircle,
|
||||
CheckCircle,
|
||||
Lightbulb,
|
||||
Search,
|
||||
ThumbsUp: ThumbUpIcon,
|
||||
ThumbsDown: ThumbDownIcon,
|
||||
};
|
||||
|
||||
function FeedbackOptionButton({
|
||||
tag,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
tag: TFeedbackTag;
|
||||
active?: boolean;
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const Icon = ICONS[tag.icon as keyof typeof ICONS] || AlertCircle;
|
||||
const label = localize(tag.label as Parameters<typeof localize>[0]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-xl p-2 text-text-secondary transition-colors duration-200 hover:bg-surface-hover hover:text-text-primary',
|
||||
active && 'bg-surface-hover font-semibold text-text-primary',
|
||||
)}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<Icon size="19" bold={active} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackButtons({
|
||||
isLast,
|
||||
feedback,
|
||||
onFeedback,
|
||||
onOther,
|
||||
}: {
|
||||
isLast: boolean;
|
||||
feedback?: TFeedback;
|
||||
onFeedback: (fb: TFeedback | undefined) => void;
|
||||
onOther?: () => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const upStore = Ariakit.usePopoverStore({ placement: 'bottom' });
|
||||
const downStore = Ariakit.usePopoverStore({ placement: 'bottom' });
|
||||
|
||||
const positiveTags = useMemo(() => getTagsForRating('thumbsUp'), []);
|
||||
const negativeTags = useMemo(() => getTagsForRating('thumbsDown'), []);
|
||||
|
||||
const upActive = feedback?.rating === 'thumbsUp' ? feedback.tag?.key : undefined;
|
||||
const downActive = feedback?.rating === 'thumbsDown' ? feedback.tag?.key : undefined;
|
||||
|
||||
const handleThumbsUpClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (feedback?.rating !== 'thumbsUp') {
|
||||
upStore.toggle();
|
||||
return;
|
||||
}
|
||||
onFeedback(undefined);
|
||||
},
|
||||
[feedback, onFeedback, upStore],
|
||||
);
|
||||
|
||||
const handleUpOption = useCallback(
|
||||
(tag: TFeedbackTag) => (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
upStore.hide();
|
||||
onFeedback({ rating: 'thumbsUp', tag });
|
||||
if (tag.key === 'other') {
|
||||
onOther?.();
|
||||
}
|
||||
},
|
||||
[onFeedback, onOther, upStore],
|
||||
);
|
||||
|
||||
const handleThumbsDownClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (feedback?.rating !== 'thumbsDown') {
|
||||
downStore.toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
onOther?.();
|
||||
},
|
||||
[feedback, onOther, downStore],
|
||||
);
|
||||
|
||||
const handleDownOption = useCallback(
|
||||
(tag: TFeedbackTag) => (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
downStore.hide();
|
||||
onFeedback({ rating: 'thumbsDown', tag });
|
||||
if (tag.key === 'other') {
|
||||
onOther?.();
|
||||
}
|
||||
},
|
||||
[onFeedback, onOther, downStore],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Ariakit.PopoverAnchor
|
||||
store={upStore}
|
||||
render={
|
||||
<button
|
||||
className={buttonClasses(feedback?.rating === 'thumbsUp', isLast)}
|
||||
onClick={handleThumbsUpClick}
|
||||
type="button"
|
||||
title={localize('com_ui_feedback_positive')}
|
||||
aria-pressed={feedback?.rating === 'thumbsUp'}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<ThumbUpIcon size="19" bold={feedback?.rating === 'thumbsUp'} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Ariakit.Popover
|
||||
store={upStore}
|
||||
gutter={8}
|
||||
portal
|
||||
unmountOnHide
|
||||
className="popover-animate flex w-auto flex-col gap-1.5 overflow-hidden rounded-2xl border border-border-medium bg-surface-secondary p-1.5 shadow-lg"
|
||||
>
|
||||
<div className="flex flex-col items-stretch justify-center">
|
||||
{positiveTags.map((tag) => (
|
||||
<FeedbackOptionButton
|
||||
key={tag.key}
|
||||
tag={tag}
|
||||
active={upActive === tag.key}
|
||||
onClick={handleUpOption(tag)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Ariakit.Popover>
|
||||
|
||||
<Ariakit.PopoverAnchor
|
||||
store={downStore}
|
||||
render={
|
||||
<button
|
||||
className={buttonClasses(feedback?.rating === 'thumbsDown', isLast)}
|
||||
onClick={handleThumbsDownClick}
|
||||
type="button"
|
||||
title={localize('com_ui_feedback_negative')}
|
||||
aria-pressed={feedback?.rating === 'thumbsDown'}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<ThumbDownIcon size="19" bold={feedback?.rating === 'thumbsDown'} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Ariakit.Popover
|
||||
store={downStore}
|
||||
gutter={8}
|
||||
portal
|
||||
unmountOnHide
|
||||
className="popover-animate flex w-auto flex-col gap-1.5 overflow-hidden rounded-2xl border border-border-medium bg-surface-secondary p-1.5 shadow-lg"
|
||||
>
|
||||
<div className="flex flex-col items-stretch justify-center">
|
||||
{negativeTags.map((tag) => (
|
||||
<FeedbackOptionButton
|
||||
key={tag.key}
|
||||
tag={tag}
|
||||
active={downActive === tag.key}
|
||||
onClick={handleDownOption(tag)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Ariakit.Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function buttonClasses(isActive: boolean, isLast: boolean) {
|
||||
return cn(
|
||||
'hover-button rounded-lg p-1.5',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white',
|
||||
'hover:bg-gray-100 hover:text-gray-500',
|
||||
'data-[state=open]:active data-[state=open]:bg-gray-100 data-[state=open]:text-gray-500',
|
||||
isActive ? 'text-gray-500 dark:text-gray-200 font-bold' : 'dark:text-gray-400/70',
|
||||
'dark:hover:bg-gray-700 dark:hover:text-gray-200',
|
||||
'data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
|
||||
'disabled:dark:hover:text-gray-400',
|
||||
isLast
|
||||
? ''
|
||||
: 'data-[state=open]:opacity-100 md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100',
|
||||
'md:group-focus-within:visible md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
);
|
||||
}
|
||||
|
||||
export default function Feedback({
|
||||
isLast = false,
|
||||
handleFeedback,
|
||||
feedback: initialFeedback,
|
||||
}: FeedbackProps) {
|
||||
const localize = useLocalize();
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [feedback, setFeedback] = useState<TFeedback | undefined>(initialFeedback);
|
||||
|
||||
useEffect(() => {
|
||||
setFeedback(initialFeedback);
|
||||
}, [initialFeedback]);
|
||||
|
||||
const propagateMinimal = useCallback(
|
||||
(fb: TFeedback | undefined) => {
|
||||
setFeedback(fb);
|
||||
handleFeedback({ feedback: fb });
|
||||
},
|
||||
[handleFeedback],
|
||||
);
|
||||
|
||||
const handleButtonFeedback = useCallback(
|
||||
(fb: TFeedback | undefined) => {
|
||||
if (fb?.tag?.key === 'other') setOpenDialog(true);
|
||||
else setOpenDialog(false);
|
||||
propagateMinimal(fb);
|
||||
},
|
||||
[propagateMinimal],
|
||||
);
|
||||
|
||||
const handleOtherOpen = useCallback(() => setOpenDialog(true), []);
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setFeedback((prev) => (prev ? { ...prev, text: e.target.value } : undefined));
|
||||
};
|
||||
|
||||
const handleDialogSave = useCallback(() => {
|
||||
if (feedback?.tag?.key === 'other' && !feedback?.text?.trim()) {
|
||||
return;
|
||||
}
|
||||
propagateMinimal(feedback);
|
||||
setOpenDialog(false);
|
||||
}, [feedback, propagateMinimal]);
|
||||
|
||||
const handleDialogClear = useCallback(() => {
|
||||
setFeedback(undefined);
|
||||
handleFeedback({ feedback: undefined });
|
||||
setOpenDialog(false);
|
||||
}, [handleFeedback]);
|
||||
|
||||
const renderSingleFeedbackButton = () => {
|
||||
if (!feedback) return null;
|
||||
const isThumbsUp = feedback.rating === 'thumbsUp';
|
||||
const Icon = isThumbsUp ? ThumbUpIcon : ThumbDownIcon;
|
||||
const label = isThumbsUp
|
||||
? localize('com_ui_feedback_positive')
|
||||
: localize('com_ui_feedback_negative');
|
||||
return (
|
||||
<button
|
||||
className={buttonClasses(true, isLast)}
|
||||
onClick={() => {
|
||||
if (isThumbsUp) {
|
||||
handleButtonFeedback(undefined);
|
||||
} else {
|
||||
setOpenDialog(true);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
title={label}
|
||||
aria-pressed="true"
|
||||
>
|
||||
<Icon size="19" bold />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{feedback ? (
|
||||
renderSingleFeedbackButton()
|
||||
) : (
|
||||
<FeedbackButtons
|
||||
isLast={isLast}
|
||||
feedback={feedback}
|
||||
onFeedback={handleButtonFeedback}
|
||||
onOther={handleOtherOpen}
|
||||
/>
|
||||
)}
|
||||
<OGDialog open={openDialog} onOpenChange={setOpenDialog}>
|
||||
<OGDialogContent className="w-11/12 max-w-lg">
|
||||
<OGDialogTitle className="text-token-text-primary text-lg font-semibold leading-6">
|
||||
{localize('com_ui_feedback_more_information')}
|
||||
</OGDialogTitle>
|
||||
<textarea
|
||||
className="w-full rounded-xl border border-border-light bg-transparent p-2 text-text-primary"
|
||||
value={feedback?.text || ''}
|
||||
onChange={handleTextChange}
|
||||
rows={4}
|
||||
placeholder={localize('com_ui_feedback_placeholder')}
|
||||
maxLength={500}
|
||||
/>
|
||||
<div className="mt-4 flex items-end justify-end gap-2">
|
||||
<Button variant="destructive" onClick={handleDialogClear}>
|
||||
{localize('com_ui_delete')}
|
||||
</Button>
|
||||
<Button variant="submit" onClick={handleDialogSave} disabled={!feedback?.text?.trim()}>
|
||||
{localize('com_ui_save')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
424
client/src/components/Chat/Messages/Fork.tsx
Normal file
424
client/src/components/Chat/Messages/Fork.tsx
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { VisuallyHidden } from '@ariakit/react';
|
||||
import { GitFork, InfoIcon } from 'lucide-react';
|
||||
import { ForkOptions } from 'librechat-data-provider';
|
||||
import { GitCommit, GitBranchPlus, ListTree } from 'lucide-react';
|
||||
import { TranslationKeys, useLocalize, useNavigateToConvo } from '~/hooks';
|
||||
import { useForkConvoMutation } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
interface PopoverButtonProps {
|
||||
children: React.ReactNode;
|
||||
setting: ForkOptions;
|
||||
onClick: (setting: ForkOptions) => void;
|
||||
setActiveSetting: React.Dispatch<React.SetStateAction<TranslationKeys>>;
|
||||
timeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
|
||||
hoverInfo?: React.ReactNode | string;
|
||||
hoverTitle?: React.ReactNode | string;
|
||||
hoverDescription?: React.ReactNode | string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const optionLabels: Record<ForkOptions, TranslationKeys> = {
|
||||
[ForkOptions.DIRECT_PATH]: 'com_ui_fork_visible',
|
||||
[ForkOptions.INCLUDE_BRANCHES]: 'com_ui_fork_branches',
|
||||
[ForkOptions.TARGET_LEVEL]: 'com_ui_fork_all_target',
|
||||
[ForkOptions.DEFAULT]: 'com_ui_fork_from_message',
|
||||
};
|
||||
|
||||
const chevronDown = (
|
||||
<svg width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PopoverButton: React.FC<PopoverButtonProps> = ({
|
||||
children,
|
||||
setting,
|
||||
onClick,
|
||||
setActiveSetting,
|
||||
timeoutRef,
|
||||
hoverInfo,
|
||||
hoverTitle,
|
||||
hoverDescription,
|
||||
label,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<Ariakit.HovercardProvider placement="right-start">
|
||||
<div className="flex flex-col items-center">
|
||||
<Ariakit.HovercardAnchor
|
||||
render={
|
||||
<Ariakit.Button
|
||||
onClick={() => onClick(setting)}
|
||||
onMouseEnter={() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
setActiveSetting(optionLabels[setting]);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setActiveSetting(optionLabels[ForkOptions.DEFAULT]);
|
||||
}, 175);
|
||||
}}
|
||||
className="mx-0.5 w-14 flex-1 rounded-xl border-2 border-border-medium bg-surface-secondary text-text-secondary transition duration-200 ease-in-out hover:bg-surface-hover hover:text-text-primary"
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
<VisuallyHidden>{label}</VisuallyHidden>
|
||||
</Ariakit.Button>
|
||||
}
|
||||
/>
|
||||
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<VisuallyHidden>
|
||||
{localize('com_ui_fork_more_details_about', { 0: label })}
|
||||
</VisuallyHidden>
|
||||
{chevronDown}
|
||||
</Ariakit.HovercardDisclosure>
|
||||
{((hoverInfo != null && hoverInfo !== '') ||
|
||||
(hoverTitle != null && hoverTitle !== '') ||
|
||||
(hoverDescription != null && hoverDescription !== '')) && (
|
||||
<Ariakit.Hovercard
|
||||
gutter={16}
|
||||
shift={40}
|
||||
flip={false}
|
||||
className="z-[999] w-80 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="flex flex-col gap-2 text-sm text-text-secondary">
|
||||
{hoverInfo && hoverInfo}
|
||||
{hoverTitle && <span className="flex flex-wrap gap-1 font-bold">{hoverTitle}</span>}
|
||||
{hoverDescription && hoverDescription}
|
||||
</p>
|
||||
</div>
|
||||
</Ariakit.Hovercard>
|
||||
)}
|
||||
</div>
|
||||
</Ariakit.HovercardProvider>
|
||||
);
|
||||
};
|
||||
|
||||
interface CheckboxOptionProps {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
onToggle: (checked: boolean) => void;
|
||||
labelKey: TranslationKeys;
|
||||
infoKey: TranslationKeys;
|
||||
showToastOnCheck?: boolean;
|
||||
}
|
||||
const CheckboxOption: React.FC<CheckboxOptionProps> = ({
|
||||
id,
|
||||
checked,
|
||||
onToggle,
|
||||
labelKey,
|
||||
infoKey,
|
||||
showToastOnCheck = false,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
return (
|
||||
<Ariakit.HovercardProvider placement="right-start">
|
||||
<div className="flex items-center">
|
||||
<div className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary">
|
||||
<Ariakit.HovercardAnchor
|
||||
render={
|
||||
<div>
|
||||
<Ariakit.Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
const value = e.target.checked;
|
||||
if (value && showToastOnCheck) {
|
||||
showToast({
|
||||
message: localize('com_ui_fork_remember_checked'),
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
onToggle(value);
|
||||
}}
|
||||
className="h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out 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"
|
||||
aria-label={localize(labelKey)}
|
||||
/>
|
||||
<label htmlFor={id} className="ml-2 cursor-pointer">
|
||||
{localize(labelKey)}
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<VisuallyHidden>{localize(infoKey)}</VisuallyHidden>
|
||||
{chevronDown}
|
||||
</Ariakit.HovercardDisclosure>
|
||||
</div>
|
||||
<Ariakit.Hovercard
|
||||
gutter={14}
|
||||
shift={40}
|
||||
flip={false}
|
||||
className="z-[999] w-80 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">{localize(infoKey)}</p>
|
||||
</div>
|
||||
</Ariakit.Hovercard>
|
||||
</Ariakit.HovercardProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Fork({
|
||||
messageId,
|
||||
conversationId: _convoId,
|
||||
forkingSupported = false,
|
||||
latestMessageId,
|
||||
isLast = false,
|
||||
}: {
|
||||
messageId: string;
|
||||
conversationId: string | null;
|
||||
forkingSupported?: boolean;
|
||||
latestMessageId?: string;
|
||||
isLast?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const [remember, setRemember] = useState(false);
|
||||
const { navigateToConvo } = useNavigateToConvo();
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting);
|
||||
const [activeSetting, setActiveSetting] = useState(optionLabels.default);
|
||||
const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget);
|
||||
const [rememberGlobal, setRememberGlobal] = useRecoilState(store.rememberDefaultFork);
|
||||
const popoverStore = Ariakit.usePopoverStore({
|
||||
placement: 'bottom',
|
||||
});
|
||||
|
||||
const buttonStyle = cn(
|
||||
'hover-button rounded-lg p-1.5',
|
||||
'hover:bg-gray-100 hover:text-gray-500',
|
||||
'dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200',
|
||||
'disabled:dark:hover:text-gray-400',
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',
|
||||
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',
|
||||
isActive && 'active text-gray-700 dark:text-gray-200 bg-gray-100 bg-gray-700',
|
||||
);
|
||||
|
||||
const forkConvo = useForkConvoMutation({
|
||||
onSuccess: (data) => {
|
||||
navigateToConvo(data.conversation);
|
||||
showToast({
|
||||
message: localize('com_ui_fork_success'),
|
||||
status: 'success',
|
||||
});
|
||||
},
|
||||
onMutate: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_fork_processing'),
|
||||
status: 'info',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_fork_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const conversationId = _convoId ?? '';
|
||||
if (!forkingSupported || !conversationId || !messageId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = (option: string) => {
|
||||
if (remember) {
|
||||
setRememberGlobal(true);
|
||||
setForkSetting(option);
|
||||
}
|
||||
|
||||
forkConvo.mutate({
|
||||
messageId,
|
||||
conversationId,
|
||||
option,
|
||||
splitAtTarget,
|
||||
latestMessageId,
|
||||
});
|
||||
};
|
||||
|
||||
const forkOptionsConfig = [
|
||||
{
|
||||
setting: ForkOptions.DIRECT_PATH,
|
||||
label: localize(optionLabels[ForkOptions.DIRECT_PATH]),
|
||||
icon: <GitCommit className="h-full w-full rotate-90 p-2" />,
|
||||
hoverTitle: (
|
||||
<>
|
||||
<GitCommit className="h-5 w-5 rotate-90" />
|
||||
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
|
||||
</>
|
||||
),
|
||||
hoverDescription: localize('com_ui_fork_info_visible'),
|
||||
},
|
||||
{
|
||||
setting: ForkOptions.INCLUDE_BRANCHES,
|
||||
label: localize(optionLabels[ForkOptions.INCLUDE_BRANCHES]),
|
||||
icon: <GitBranchPlus className="h-full w-full rotate-180 p-2" />,
|
||||
hoverTitle: (
|
||||
<>
|
||||
<GitBranchPlus className="h-4 w-4 rotate-180" />
|
||||
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
|
||||
</>
|
||||
),
|
||||
hoverDescription: localize('com_ui_fork_info_branches'),
|
||||
},
|
||||
{
|
||||
setting: ForkOptions.TARGET_LEVEL,
|
||||
label: localize(optionLabels[ForkOptions.TARGET_LEVEL]),
|
||||
icon: <ListTree className="h-full w-full p-2" />,
|
||||
hoverTitle: (
|
||||
<>
|
||||
<ListTree className="h-5 w-5" />
|
||||
{`${localize(optionLabels[ForkOptions.TARGET_LEVEL])} (${localize('com_endpoint_default')})`}
|
||||
</>
|
||||
),
|
||||
hoverDescription: localize('com_ui_fork_info_target'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Ariakit.PopoverAnchor
|
||||
store={popoverStore}
|
||||
render={
|
||||
<button
|
||||
className={buttonStyle}
|
||||
onClick={(e) => {
|
||||
if (rememberGlobal) {
|
||||
e.preventDefault();
|
||||
forkConvo.mutate({
|
||||
messageId,
|
||||
splitAtTarget,
|
||||
conversationId,
|
||||
option: forkSetting,
|
||||
latestMessageId,
|
||||
});
|
||||
} else {
|
||||
popoverStore.toggle();
|
||||
setIsActive(popoverStore.getState().open);
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
aria-label={localize('com_ui_fork')}
|
||||
>
|
||||
<GitFork size="19" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Ariakit.Popover
|
||||
store={popoverStore}
|
||||
gutter={10}
|
||||
className={`popover-animate ${isActive ? 'open' : ''} flex w-60 flex-col gap-3 overflow-hidden rounded-2xl border border-border-medium bg-surface-secondary p-2 px-4 shadow-lg`}
|
||||
style={{
|
||||
outline: 'none',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 50,
|
||||
}}
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
onClose={() => setIsActive(false)}
|
||||
>
|
||||
<div className="flex h-8 w-full items-center justify-center text-sm text-text-primary">
|
||||
{localize(activeSetting)}
|
||||
<Ariakit.HovercardProvider placement="right-start">
|
||||
<div className="ml-auto flex h-6 w-6 items-center justify-center gap-1">
|
||||
<Ariakit.HovercardAnchor
|
||||
render={
|
||||
<button
|
||||
className="flex h-5 w-5 cursor-help items-center rounded-full text-text-secondary"
|
||||
aria-label={localize('com_ui_fork_info_button_label')}
|
||||
>
|
||||
<InfoIcon />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<VisuallyHidden>{localize('com_ui_fork_more_info_options')}</VisuallyHidden>
|
||||
{chevronDown}
|
||||
</Ariakit.HovercardDisclosure>
|
||||
</div>
|
||||
<Ariakit.Hovercard
|
||||
gutter={19}
|
||||
shift={40}
|
||||
flip={false}
|
||||
className="z-[999] w-80 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="flex flex-col gap-2 space-y-2 text-sm text-text-secondary">
|
||||
<span>{localize('com_ui_fork_info_1')}</span>
|
||||
<span>{localize('com_ui_fork_info_2')}</span>
|
||||
<span>
|
||||
{localize('com_ui_fork_info_3', {
|
||||
0: localize('com_ui_fork_split_target'),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Ariakit.Hovercard>
|
||||
</Ariakit.HovercardProvider>
|
||||
</div>
|
||||
<div className="flex h-full w-full items-center justify-center gap-1">
|
||||
{forkOptionsConfig.map((opt) => (
|
||||
<PopoverButton
|
||||
key={opt.setting}
|
||||
setActiveSetting={setActiveSetting}
|
||||
timeoutRef={timeoutRef}
|
||||
onClick={onClick}
|
||||
setting={opt.setting}
|
||||
label={opt.label}
|
||||
hoverTitle={opt.hoverTitle}
|
||||
hoverDescription={opt.hoverDescription}
|
||||
>
|
||||
{opt.icon}
|
||||
</PopoverButton>
|
||||
))}
|
||||
</div>
|
||||
<CheckboxOption
|
||||
id="split-target-checkbox"
|
||||
checked={splitAtTarget}
|
||||
onToggle={setSplitAtTarget}
|
||||
labelKey="com_ui_fork_split_target"
|
||||
infoKey="com_ui_fork_info_start"
|
||||
/>
|
||||
<CheckboxOption
|
||||
id="remember-checkbox"
|
||||
checked={remember}
|
||||
onToggle={(checked) => {
|
||||
if (checked)
|
||||
showToast({ message: localize('com_ui_fork_remember_checked'), status: 'info' });
|
||||
setRemember(checked);
|
||||
}}
|
||||
labelKey="com_ui_fork_remember"
|
||||
infoKey="com_ui_fork_info_remember"
|
||||
showToastOnCheck
|
||||
/>
|
||||
</Ariakit.Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo, memo } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import type { TConversation, TMessage } from 'librechat-data-provider';
|
||||
import { EditIcon, Clipboard, CheckMark, ContinueIcon, RegenerateIcon } from '~/components/svg';
|
||||
import type { TConversation, TMessage, TFeedback } from 'librechat-data-provider';
|
||||
import { EditIcon, Clipboard, CheckMark, ContinueIcon, RegenerateIcon } from '~/components';
|
||||
import { useGenerationsByLatest, useLocalize } from '~/hooks';
|
||||
import { Fork } from '~/components/Conversations';
|
||||
import MessageAudio from './MessageAudio';
|
||||
import Feedback from './Feedback';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
|
|
@ -20,9 +21,97 @@ type THoverButtons = {
|
|||
latestMessage: TMessage | null;
|
||||
isLast: boolean;
|
||||
index: number;
|
||||
handleFeedback: ({ feedback }: { feedback: TFeedback | undefined }) => void;
|
||||
};
|
||||
|
||||
export default function HoverButtons({
|
||||
type HoverButtonProps = {
|
||||
onClick: (e?: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
isActive?: boolean;
|
||||
isVisible?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isLast?: boolean;
|
||||
className?: string;
|
||||
buttonStyle?: string;
|
||||
};
|
||||
|
||||
const extractMessageContent = (message: TMessage): string => {
|
||||
if (typeof message.content === 'string') {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
if (Array.isArray(message.content)) {
|
||||
return message.content
|
||||
.map((part) => {
|
||||
if (typeof part === 'string') {
|
||||
return part;
|
||||
}
|
||||
if ('text' in part) {
|
||||
return part.text || '';
|
||||
}
|
||||
if ('think' in part) {
|
||||
const think = part.think;
|
||||
if (typeof think === 'string') {
|
||||
return think;
|
||||
}
|
||||
return think && 'text' in think ? think.text || '' : '';
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
return message.text || '';
|
||||
};
|
||||
|
||||
const HoverButton = memo(
|
||||
({
|
||||
onClick,
|
||||
title,
|
||||
icon,
|
||||
isActive = false,
|
||||
isVisible = true,
|
||||
isDisabled = false,
|
||||
isLast = false,
|
||||
className = '',
|
||||
}: HoverButtonProps) => {
|
||||
const buttonStyle = cn(
|
||||
'hover-button rounded-lg p-1.5',
|
||||
|
||||
'hover:bg-gray-100 hover:text-gray-500',
|
||||
|
||||
'dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200',
|
||||
'disabled:dark:hover:text-gray-400',
|
||||
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',
|
||||
!isVisible && 'opacity-0',
|
||||
|
||||
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',
|
||||
|
||||
isActive && isVisible && 'active text-gray-700 dark:text-gray-200 bg-gray-100 bg-gray-700',
|
||||
|
||||
className,
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={buttonStyle}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
title={title}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
HoverButton.displayName = 'HoverButton';
|
||||
|
||||
const HoverButtons = ({
|
||||
index,
|
||||
isEditing,
|
||||
enterEdit,
|
||||
|
|
@ -34,20 +123,20 @@ export default function HoverButtons({
|
|||
handleContinue,
|
||||
latestMessage,
|
||||
isLast,
|
||||
}: THoverButtons) {
|
||||
handleFeedback,
|
||||
}: THoverButtons) => {
|
||||
const localize = useLocalize();
|
||||
const { endpoint: _endpoint, endpointType } = conversation ?? {};
|
||||
const endpoint = endpointType ?? _endpoint;
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [TextToSpeech] = useRecoilState<boolean>(store.textToSpeech);
|
||||
|
||||
const {
|
||||
hideEditButton,
|
||||
regenerateEnabled,
|
||||
continueSupported,
|
||||
forkingSupported,
|
||||
isEditableEndpoint,
|
||||
} = useGenerationsByLatest({
|
||||
const endpoint = useMemo(() => {
|
||||
if (!conversation) {
|
||||
return '';
|
||||
}
|
||||
return conversation.endpointType ?? conversation.endpoint;
|
||||
}, [conversation]);
|
||||
|
||||
const generationCapabilities = useGenerationsByLatest({
|
||||
isEditing,
|
||||
isSubmitting,
|
||||
error: message.error,
|
||||
|
|
@ -58,38 +147,44 @@ export default function HoverButtons({
|
|||
isCreatedByUser: message.isCreatedByUser,
|
||||
latestMessageId: latestMessage?.messageId,
|
||||
});
|
||||
|
||||
const {
|
||||
hideEditButton,
|
||||
regenerateEnabled,
|
||||
continueSupported,
|
||||
forkingSupported,
|
||||
isEditableEndpoint,
|
||||
} = generationCapabilities;
|
||||
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isCreatedByUser, error } = message;
|
||||
|
||||
const renderRegenerate = () => {
|
||||
if (!regenerateEnabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'hover-button active rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
|
||||
)}
|
||||
onClick={regenerate}
|
||||
type="button"
|
||||
title={localize('com_ui_regenerate')}
|
||||
>
|
||||
<RegenerateIcon
|
||||
className="hover:text-gray-500 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400"
|
||||
size="19"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
const buttonStyle = cn(
|
||||
'hover-button rounded-lg p-1.5',
|
||||
'hover:bg-gray-100 hover:text-gray-500',
|
||||
'dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200',
|
||||
'disabled:dark:hover:text-gray-400',
|
||||
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
|
||||
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',
|
||||
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',
|
||||
'active text-gray-700 dark:text-gray-200 bg-gray-100 bg-gray-700',
|
||||
);
|
||||
|
||||
// If message has an error, only show regenerate button
|
||||
if (error === true) {
|
||||
return (
|
||||
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-500 lg:justify-start">
|
||||
{renderRegenerate()}
|
||||
<div className="visible flex justify-center self-end lg:justify-start">
|
||||
{regenerateEnabled && (
|
||||
<HoverButton
|
||||
onClick={regenerate}
|
||||
title={localize('com_ui_regenerate')}
|
||||
icon={<RegenerateIcon size="19" />}
|
||||
isLast={isLast}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -101,72 +196,92 @@ export default function HoverButtons({
|
|||
enterEdit();
|
||||
};
|
||||
|
||||
const handleCopy = () => copyToClipboard(setIsCopied);
|
||||
|
||||
return (
|
||||
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-500 lg:justify-start">
|
||||
<div className="group visible flex justify-center gap-0.5 self-end focus-within:outline-none lg:justify-start">
|
||||
{/* Text to Speech */}
|
||||
{TextToSpeech && (
|
||||
<MessageAudio
|
||||
index={index}
|
||||
messageId={message.messageId}
|
||||
content={message.content ?? message.text}
|
||||
content={extractMessageContent(message)}
|
||||
isLast={isLast}
|
||||
className={cn(
|
||||
'ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
renderButton={(props) => (
|
||||
<HoverButton
|
||||
onClick={props.onClick}
|
||||
title={props.title}
|
||||
icon={props.icon}
|
||||
isActive={props.isActive}
|
||||
isLast={isLast}
|
||||
className={props.className}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{isEditableEndpoint && (
|
||||
<button
|
||||
id={`edit-${message.messageId}`}
|
||||
className={cn(
|
||||
'hover-button rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
isCreatedByUser ? '' : 'active',
|
||||
hideEditButton ? 'opacity-0' : '',
|
||||
isEditing ? 'active text-gray-700 dark:text-gray-200' : '',
|
||||
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
|
||||
)}
|
||||
onClick={onEdit}
|
||||
type="button"
|
||||
title={localize('com_ui_edit')}
|
||||
disabled={hideEditButton}
|
||||
>
|
||||
<EditIcon size="19" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={cn(
|
||||
'ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
isSubmitting && isCreatedByUser ? 'md:opacity-0 md:group-hover:opacity-100' : '',
|
||||
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
|
||||
)}
|
||||
onClick={() => copyToClipboard(setIsCopied)}
|
||||
type="button"
|
||||
|
||||
{/* Copy Button */}
|
||||
<HoverButton
|
||||
onClick={handleCopy}
|
||||
title={
|
||||
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
|
||||
}
|
||||
>
|
||||
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
|
||||
</button>
|
||||
{renderRegenerate()}
|
||||
<Fork
|
||||
icon={isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard size="19" />}
|
||||
isLast={isLast}
|
||||
className={`ml-0 flex items-center gap-1.5 text-xs ${isSubmitting && isCreatedByUser ? 'md:opacity-0 md:group-hover:opacity-100' : ''}`}
|
||||
/>
|
||||
|
||||
{/* Edit Button */}
|
||||
{isEditableEndpoint && (
|
||||
<HoverButton
|
||||
onClick={onEdit}
|
||||
title={localize('com_ui_edit')}
|
||||
icon={<EditIcon size="19" />}
|
||||
isActive={isEditing}
|
||||
isVisible={!hideEditButton}
|
||||
isDisabled={hideEditButton}
|
||||
isLast={isLast}
|
||||
className={isCreatedByUser ? '' : 'active'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fork Button */}
|
||||
<Fork
|
||||
messageId={message.messageId}
|
||||
conversationId={conversation.conversationId}
|
||||
forkingSupported={forkingSupported}
|
||||
latestMessageId={latestMessage?.messageId}
|
||||
isLast={isLast}
|
||||
/>
|
||||
{continueSupported === true ? (
|
||||
<button
|
||||
className={cn(
|
||||
'hover-button active rounded-md p-1 hover:bg-gray-100 hover:text-gray-500 focus:opacity-100 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
|
||||
!isLast ? 'md:opacity-0 md:group-hover:opacity-100' : '',
|
||||
)}
|
||||
onClick={handleContinue}
|
||||
type="button"
|
||||
|
||||
{/* Feedback Buttons */}
|
||||
{!isCreatedByUser && (
|
||||
<Feedback handleFeedback={handleFeedback} feedback={message.feedback} isLast={isLast} />
|
||||
)}
|
||||
|
||||
{/* Regenerate Button */}
|
||||
{regenerateEnabled && (
|
||||
<HoverButton
|
||||
onClick={regenerate}
|
||||
title={localize('com_ui_regenerate')}
|
||||
icon={<RegenerateIcon size="19" />}
|
||||
isLast={isLast}
|
||||
className="active"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Continue Button */}
|
||||
{continueSupported && (
|
||||
<HoverButton
|
||||
onClick={(e) => e && handleContinue(e)}
|
||||
title={localize('com_ui_continue')}
|
||||
>
|
||||
<ContinueIcon className="h-4 w-4 hover:text-gray-500 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
|
||||
</button>
|
||||
) : null}
|
||||
icon={<ContinueIcon className="w-19 h-19 -rotate-180" />}
|
||||
isLast={isLast}
|
||||
className="active"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default memo(HoverButtons);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useMemo, memo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useCallback, useMemo, memo } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { type TMessage } from 'librechat-data-provider';
|
||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
|
||||
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
|
||||
|
|
@ -51,13 +51,13 @@ const MessageRender = memo(
|
|||
copyToClipboard,
|
||||
setLatestMessage,
|
||||
regenerateMessage,
|
||||
handleFeedback,
|
||||
} = useMessageActions({
|
||||
message: msg,
|
||||
currentEditId,
|
||||
isMultiMessage,
|
||||
setCurrentEditId,
|
||||
});
|
||||
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
const fontSize = useRecoilValue(store.fontSize);
|
||||
|
||||
|
|
@ -206,6 +206,7 @@ const MessageRender = memo(
|
|||
copyToClipboard={copyToClipboard}
|
||||
handleContinue={handleContinue}
|
||||
latestMessage={latestMessage}
|
||||
handleFeedback={handleFeedback}
|
||||
isLast={isLast}
|
||||
/>
|
||||
</SubRow>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function TemporaryChat() {
|
|||
render={
|
||||
<button
|
||||
onClick={handleBadgeToggle}
|
||||
aria-label={localize(temporaryBadge.label)}
|
||||
aria-label={localize(temporaryBadge.label)}
|
||||
className={cn(
|
||||
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out hover:bg-surface-tertiary',
|
||||
isTemporary
|
||||
|
|
|
|||
|
|
@ -1,408 +0,0 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { VisuallyHidden } from '@ariakit/react';
|
||||
import { GitFork, InfoIcon } from 'lucide-react';
|
||||
import { ForkOptions } from 'librechat-data-provider';
|
||||
import { GitCommit, GitBranchPlus, ListTree } from 'lucide-react';
|
||||
import { TranslationKeys, useLocalize, useNavigateToConvo } from '~/hooks';
|
||||
import { useForkConvoMutation } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
interface PopoverButtonProps {
|
||||
children: React.ReactNode;
|
||||
setting: ForkOptions;
|
||||
onClick: (setting: ForkOptions) => void;
|
||||
setActiveSetting: React.Dispatch<React.SetStateAction<TranslationKeys>>;
|
||||
timeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
|
||||
hoverInfo?: React.ReactNode | string;
|
||||
hoverTitle?: React.ReactNode | string;
|
||||
hoverDescription?: React.ReactNode | string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const optionLabels: Record<ForkOptions, TranslationKeys> = {
|
||||
[ForkOptions.DIRECT_PATH]: 'com_ui_fork_visible',
|
||||
[ForkOptions.INCLUDE_BRANCHES]: 'com_ui_fork_branches',
|
||||
[ForkOptions.TARGET_LEVEL]: 'com_ui_fork_all_target',
|
||||
[ForkOptions.DEFAULT]: 'com_ui_fork_from_message',
|
||||
};
|
||||
|
||||
const chevronDown = (
|
||||
<svg width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PopoverButton: React.FC<PopoverButtonProps> = ({
|
||||
children,
|
||||
setting,
|
||||
onClick,
|
||||
setActiveSetting,
|
||||
timeoutRef,
|
||||
hoverInfo,
|
||||
hoverTitle,
|
||||
hoverDescription,
|
||||
label,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<Ariakit.HovercardProvider>
|
||||
<div className="flex flex-col items-center">
|
||||
<Ariakit.HovercardAnchor
|
||||
render={
|
||||
<Ariakit.Button
|
||||
onClick={() => onClick(setting)}
|
||||
onMouseEnter={() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
setActiveSetting(optionLabels[setting]);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setActiveSetting(optionLabels[ForkOptions.DEFAULT]);
|
||||
}, 175);
|
||||
}}
|
||||
className="mx-1 max-w-14 flex-1 rounded-lg border-2 border-border-medium bg-surface-secondary text-text-secondary transition duration-300 ease-in-out hover:border-border-xheavy hover:bg-surface-hover hover:text-text-primary"
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
<VisuallyHidden>{label}</VisuallyHidden>
|
||||
</Ariakit.Button>
|
||||
}
|
||||
/>
|
||||
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<VisuallyHidden>
|
||||
{localize('com_ui_fork_more_details_about', { 0: label })}
|
||||
</VisuallyHidden>
|
||||
{chevronDown}
|
||||
</Ariakit.HovercardDisclosure>
|
||||
{((hoverInfo != null && hoverInfo !== '') ||
|
||||
(hoverTitle != null && hoverTitle !== '') ||
|
||||
(hoverDescription != null && hoverDescription !== '')) && (
|
||||
<Ariakit.Hovercard
|
||||
gutter={16}
|
||||
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="flex flex-col gap-2 text-sm text-text-secondary">
|
||||
{hoverInfo && hoverInfo}
|
||||
{hoverTitle && <span className="flex flex-wrap gap-1 font-bold">{hoverTitle}</span>}
|
||||
{hoverDescription && hoverDescription}
|
||||
</p>
|
||||
</div>
|
||||
</Ariakit.Hovercard>
|
||||
)}
|
||||
</div>
|
||||
</Ariakit.HovercardProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Fork({
|
||||
isLast = false,
|
||||
messageId,
|
||||
conversationId: _convoId,
|
||||
forkingSupported = false,
|
||||
latestMessageId,
|
||||
}: {
|
||||
isLast?: boolean;
|
||||
messageId: string;
|
||||
conversationId: string | null;
|
||||
forkingSupported?: boolean;
|
||||
latestMessageId?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const [remember, setRemember] = useState(false);
|
||||
const { navigateToConvo } = useNavigateToConvo();
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting);
|
||||
const [activeSetting, setActiveSetting] = useState(optionLabels.default);
|
||||
const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget);
|
||||
const [rememberGlobal, setRememberGlobal] = useRecoilState(store.rememberDefaultFork);
|
||||
const popoverStore = Ariakit.usePopoverStore({
|
||||
placement: 'top',
|
||||
});
|
||||
const forkConvo = useForkConvoMutation({
|
||||
onSuccess: (data) => {
|
||||
navigateToConvo(data.conversation);
|
||||
showToast({
|
||||
message: localize('com_ui_fork_success'),
|
||||
status: 'success',
|
||||
});
|
||||
},
|
||||
onMutate: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_fork_processing'),
|
||||
status: 'info',
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_fork_error'),
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const conversationId = _convoId ?? '';
|
||||
if (!forkingSupported || !conversationId || !messageId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = (option: string) => {
|
||||
if (remember) {
|
||||
setRememberGlobal(true);
|
||||
setForkSetting(option);
|
||||
}
|
||||
|
||||
forkConvo.mutate({
|
||||
messageId,
|
||||
conversationId,
|
||||
option,
|
||||
splitAtTarget,
|
||||
latestMessageId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Ariakit.PopoverAnchor
|
||||
store={popoverStore}
|
||||
render={
|
||||
<button
|
||||
className={cn(
|
||||
'hover-button active rounded-md p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
|
||||
'data-[state=open]:active focus:opacity-100 data-[state=open]:bg-gray-100 data-[state=open]:text-gray-500 data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
|
||||
!isLast
|
||||
? 'data-[state=open]:opacity-100 md:opacity-0 md:group-hover:opacity-100'
|
||||
: '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (rememberGlobal) {
|
||||
e.preventDefault();
|
||||
forkConvo.mutate({
|
||||
messageId,
|
||||
splitAtTarget,
|
||||
conversationId,
|
||||
option: forkSetting,
|
||||
latestMessageId,
|
||||
});
|
||||
} else {
|
||||
popoverStore.toggle();
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
aria-label={localize('com_ui_fork')}
|
||||
>
|
||||
<GitFork className="h-4 w-4 hover:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Ariakit.Popover
|
||||
store={popoverStore}
|
||||
gutter={5}
|
||||
className="flex min-h-[120px] min-w-[215px] flex-col gap-3 overflow-hidden rounded-lg border border-border-heavy bg-surface-secondary p-2 px-3 shadow-lg"
|
||||
style={{
|
||||
outline: 'none',
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 50,
|
||||
}}
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="flex h-8 w-full items-center justify-center text-sm text-text-primary">
|
||||
{localize(activeSetting)}
|
||||
<Ariakit.HovercardProvider>
|
||||
<div className="ml-auto flex h-6 w-6 items-center justify-center gap-1">
|
||||
<Ariakit.HovercardAnchor
|
||||
render={
|
||||
<button
|
||||
className="flex h-5 w-5 items-center rounded-full text-text-secondary"
|
||||
aria-label={localize('com_ui_fork_info_button_label')}
|
||||
>
|
||||
<InfoIcon />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<VisuallyHidden>{localize('com_ui_fork_more_info_options')}</VisuallyHidden>
|
||||
{chevronDown}
|
||||
</Ariakit.HovercardDisclosure>
|
||||
</div>
|
||||
<Ariakit.Hovercard
|
||||
gutter={19}
|
||||
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="flex flex-col gap-2 space-y-2 text-sm text-text-secondary">
|
||||
<span>{localize('com_ui_fork_info_1')}</span>
|
||||
<span>{localize('com_ui_fork_info_2')}</span>
|
||||
<span>
|
||||
{localize('com_ui_fork_info_3', {
|
||||
0: localize('com_ui_fork_split_target'),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Ariakit.Hovercard>
|
||||
</Ariakit.HovercardProvider>
|
||||
</div>
|
||||
<div className="flex h-full w-full items-center justify-center gap-1">
|
||||
<PopoverButton
|
||||
setActiveSetting={setActiveSetting}
|
||||
timeoutRef={timeoutRef}
|
||||
onClick={onClick}
|
||||
setting={ForkOptions.DIRECT_PATH}
|
||||
label={localize(optionLabels[ForkOptions.DIRECT_PATH])}
|
||||
hoverTitle={
|
||||
<>
|
||||
<GitCommit className="h-5 w-5 rotate-90" />
|
||||
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
|
||||
</>
|
||||
}
|
||||
hoverDescription={localize('com_ui_fork_info_visible')}
|
||||
>
|
||||
<GitCommit className="h-full w-full rotate-90 p-2" />
|
||||
</PopoverButton>
|
||||
<PopoverButton
|
||||
setActiveSetting={setActiveSetting}
|
||||
timeoutRef={timeoutRef}
|
||||
onClick={onClick}
|
||||
setting={ForkOptions.INCLUDE_BRANCHES}
|
||||
label={localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
|
||||
hoverTitle={
|
||||
<>
|
||||
<GitBranchPlus className="h-4 w-4 rotate-180" />
|
||||
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
|
||||
</>
|
||||
}
|
||||
hoverDescription={localize('com_ui_fork_info_branches')}
|
||||
>
|
||||
<GitBranchPlus className="h-full w-full rotate-180 p-2" />
|
||||
</PopoverButton>
|
||||
<PopoverButton
|
||||
setActiveSetting={setActiveSetting}
|
||||
timeoutRef={timeoutRef}
|
||||
onClick={onClick}
|
||||
setting={ForkOptions.TARGET_LEVEL}
|
||||
label={localize(optionLabels[ForkOptions.TARGET_LEVEL])}
|
||||
hoverTitle={
|
||||
<>
|
||||
<ListTree className="h-5 w-5" />
|
||||
{`${localize(
|
||||
optionLabels[ForkOptions.TARGET_LEVEL],
|
||||
)} (${localize('com_endpoint_default')})`}
|
||||
</>
|
||||
}
|
||||
hoverDescription={localize('com_ui_fork_info_target')}
|
||||
>
|
||||
<ListTree className="h-full w-full p-2" />
|
||||
</PopoverButton>
|
||||
</div>
|
||||
<Ariakit.HovercardProvider>
|
||||
<div className="flex items-center">
|
||||
<Ariakit.HovercardAnchor
|
||||
render={
|
||||
<div className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary">
|
||||
<Ariakit.Checkbox
|
||||
id="split-target-checkbox"
|
||||
checked={splitAtTarget}
|
||||
onChange={(event) => setSplitAtTarget(event.target.checked)}
|
||||
className="m-2 h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out 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"
|
||||
aria-label={localize('com_ui_fork_split_target')}
|
||||
/>
|
||||
<label htmlFor="split-target-checkbox" className="ml-2 cursor-pointer">
|
||||
{localize('com_ui_fork_split_target')}
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<VisuallyHidden>
|
||||
{localize('com_ui_fork_more_info_split_target', {
|
||||
0: localize('com_ui_fork_split_target'),
|
||||
})}
|
||||
</VisuallyHidden>
|
||||
{chevronDown}
|
||||
</Ariakit.HovercardDisclosure>
|
||||
</div>
|
||||
<Ariakit.Hovercard
|
||||
gutter={32}
|
||||
className="z-[999] w-80 select-none rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_fork_info_start')}</p>
|
||||
</div>
|
||||
</Ariakit.Hovercard>
|
||||
</Ariakit.HovercardProvider>
|
||||
<Ariakit.HovercardProvider>
|
||||
<div className="flex items-center">
|
||||
<Ariakit.HovercardAnchor
|
||||
render={
|
||||
<div
|
||||
onClick={() => setRemember((prev) => !prev)}
|
||||
className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
<Ariakit.Checkbox
|
||||
id="remember-checkbox"
|
||||
checked={remember}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked;
|
||||
console.log('checked', checked);
|
||||
if (checked) {
|
||||
showToast({
|
||||
message: localize('com_ui_fork_remember_checked'),
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
return setRemember(checked);
|
||||
}}
|
||||
className="m-2 h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out 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"
|
||||
aria-label={localize('com_ui_fork_remember')}
|
||||
/>
|
||||
<label htmlFor="remember-checkbox" className="ml-2 cursor-pointer">
|
||||
{localize('com_ui_fork_remember')}
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
|
||||
<VisuallyHidden>
|
||||
{localize('com_ui_fork_more_info_remember', {
|
||||
0: localize('com_ui_fork_remember'),
|
||||
})}
|
||||
</VisuallyHidden>
|
||||
{chevronDown}
|
||||
</Ariakit.HovercardDisclosure>
|
||||
</div>
|
||||
<Ariakit.Hovercard
|
||||
gutter={14}
|
||||
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-secondary">{localize('com_ui_fork_info_remember')}</p>
|
||||
</div>
|
||||
</Ariakit.Hovercard>
|
||||
</Ariakit.HovercardProvider>
|
||||
</Ariakit.Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export { default as Fork } from './Fork';
|
||||
export { default as Fork } from '../Chat/Messages/Fork';
|
||||
export { default as Pages } from './Pages';
|
||||
export { default as Conversations } from './Conversations';
|
||||
export * from './ConvoOptions';
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
import type { TGenButtonProps } from '~/common';
|
||||
import { ContinueIcon } from '~/components/svg';
|
||||
import Button from './Button';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function Continue({ onClick }: TGenButtonProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<Button type="continue" onClick={onClick}>
|
||||
<ContinueIcon className="text-gray-600/90 dark:text-gray-400 " />
|
||||
{localize('com_ui_continue')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { render, fireEvent } from 'test/layout-test-utils';
|
||||
import Continue from '../Continue';
|
||||
|
||||
describe('Continue', () => {
|
||||
it('should render the Continue button', () => {
|
||||
const { getByText } = render(
|
||||
<Continue
|
||||
onClick={() => {
|
||||
('');
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(getByText('Continue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onClick when the button is clicked', () => {
|
||||
const handleClick = jest.fn();
|
||||
const { getByText } = render(<Continue onClick={handleClick} />);
|
||||
fireEvent.click(getByText('Continue'));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -52,6 +52,7 @@ const ContentRender = memo(
|
|||
copyToClipboard,
|
||||
setLatestMessage,
|
||||
regenerateMessage,
|
||||
handleFeedback,
|
||||
} = useMessageActions({
|
||||
message: msg,
|
||||
searchResults,
|
||||
|
|
@ -59,7 +60,6 @@ const ContentRender = memo(
|
|||
isMultiMessage,
|
||||
setCurrentEditId,
|
||||
});
|
||||
|
||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||
const fontSize = useRecoilValue(store.fontSize);
|
||||
|
||||
|
|
@ -199,6 +199,7 @@ const ContentRender = memo(
|
|||
copyToClipboard={copyToClipboard}
|
||||
handleContinue={handleContinue}
|
||||
latestMessage={latestMessage}
|
||||
handleFeedback={handleFeedback}
|
||||
isLast={isLast}
|
||||
/>
|
||||
</SubRow>
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function AutoScrollSwitch({
|
||||
onCheckedChange,
|
||||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const [autoScroll, setAutoScroll] = useRecoilState<boolean>(store.autoScroll);
|
||||
const localize = useLocalize();
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setAutoScroll(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div> {localize('com_nav_auto_scroll')} </div>
|
||||
<Switch
|
||||
id="autoScroll"
|
||||
checked={autoScroll}
|
||||
aria-label="Auto-Scroll to latest message on chat open"
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="autoScroll"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import Cookies from 'js-cookie';
|
||||
import React, { useContext, useCallback } from 'react';
|
||||
import UserMsgMarkdownSwitch from './UserMsgMarkdownSwitch';
|
||||
import HideSidePanelSwitch from './HideSidePanelSwitch';
|
||||
import Cookies from 'js-cookie';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { ThemeContext, useLocalize } from '~/hooks';
|
||||
import AutoScrollSwitch from './AutoScrollSwitch';
|
||||
import ArchivedChats from './ArchivedChats';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
import { Dropdown } from '~/components';
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function HideSidePanelSwitch({
|
||||
onCheckedChange,
|
||||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const [hideSidePanel, setHideSidePanel] = useRecoilState<boolean>(store.hideSidePanel);
|
||||
const localize = useLocalize();
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setHideSidePanel(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>{localize('com_nav_hide_panel')}</div>
|
||||
|
||||
<Switch
|
||||
id="hideSidePanel"
|
||||
checked={hideSidePanel}
|
||||
aria-label="Hide right-most side panel"
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="hideSidePanel"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Switch } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function UserMsgMarkdownSwitch({
|
||||
onCheckedChange,
|
||||
}: {
|
||||
onCheckedChange?: (value: boolean) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [enableUserMsgMarkdown, setEnableUserMsgMarkdown] = useRecoilState<boolean>(
|
||||
store.enableUserMsgMarkdown,
|
||||
);
|
||||
|
||||
const handleCheckedChange = (value: boolean) => {
|
||||
setEnableUserMsgMarkdown(value);
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div> {localize('com_nav_user_msg_markdown')} </div>
|
||||
<Switch
|
||||
id="enableUserMsgMarkdown"
|
||||
checked={enableUserMsgMarkdown}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
className="ml-4"
|
||||
data-testid="enableUserMsgMarkdown"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,9 +14,9 @@ const EngineSTTDropdown: React.FC<EngineSTTDropdownProps> = ({ external }) => {
|
|||
|
||||
const endpointOptions = external
|
||||
? [
|
||||
{ value: 'browser', label: localize('com_nav_browser') },
|
||||
{ value: 'external', label: localize('com_nav_external') },
|
||||
]
|
||||
{ value: 'browser', label: localize('com_nav_browser') },
|
||||
{ value: 'external', label: localize('com_nav_external') },
|
||||
]
|
||||
: [{ value: 'browser', label: localize('com_nav_browser') }];
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ const EngineTTSDropdown: React.FC<EngineTTSDropdownProps> = ({ external }) => {
|
|||
|
||||
const endpointOptions = external
|
||||
? [
|
||||
{ value: 'browser', label: localize('com_nav_browser') },
|
||||
{ value: 'external', label: localize('com_nav_external') },
|
||||
]
|
||||
{ value: 'browser', label: localize('com_nav_browser') },
|
||||
{ value: 'external', label: localize('com_nav_external') },
|
||||
]
|
||||
: [{ value: 'browser', label: localize('com_nav_browser') }];
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ export default function ContinueIcon({ className = '' }: { className?: string })
|
|||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn('h-3 w-3 -rotate-180', className)}
|
||||
height="1em"
|
||||
width="1em"
|
||||
height="19"
|
||||
width="19"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polygon points="11 19 2 12 11 5 11 19" />
|
||||
|
|
|
|||
39
client/src/components/svg/ThumbDownIcon.tsx
Normal file
39
client/src/components/svg/ThumbDownIcon.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { cn } from '~/utils';
|
||||
|
||||
export default function ThumbDownIcon({ className = '', size = '1em', bold = false }) {
|
||||
return bold ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={size}
|
||||
width={size}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M11.4079 21.4961C11.1953 21.8698 10.7683 22.0649 10.348 21.9805C8.4373 21.5968 7.27541 19.6391 7.84844 17.7691L8.69697 14.9999L6.98577 14.9999C4.35915 14.9999 2.45151 12.492 3.14262 9.94747L4.50063 4.94747C4.97329 3.20722 6.54741 1.99994 8.34378 1.99994H14.0328C15.131 2.00207 16.0206 2.89668 16.0206 3.99994V14.9999H15.6827C15.3253 14.9999 14.9953 15.1922 14.818 15.5038L11.4079 21.4961Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
<path
|
||||
d="M18.0124 14.9999C19.6624 14.9999 21 13.6568 21 11.9999V4.99994C21 3.34308 19.6624 1.99994 18.0124 1.99994H17.4794C17.8184 2.58829 18.0124 3.27136 18.0124 3.99994V14.9999Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={size}
|
||||
width={size}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M11.8727 21.4961C11.6725 21.8466 11.2811 22.0423 10.8805 21.9922L10.4267 21.9355C7.95958 21.6271 6.36855 19.1665 7.09975 16.7901L7.65054 15H6.93226C4.29476 15 2.37923 12.4921 3.0732 9.94753L4.43684 4.94753C4.91145 3.20728 6.49209 2 8.29589 2H18.0045C19.6614 2 21.0045 3.34315 21.0045 5V12C21.0045 13.6569 19.6614 15 18.0045 15H16.0045C15.745 15 15.5054 15.1391 15.3766 15.3644L11.8727 21.4961ZM14.0045 4H8.29589C7.39399 4 6.60367 4.60364 6.36637 5.47376L5.00273 10.4738C4.65574 11.746 5.61351 13 6.93226 13H9.00451C9.32185 13 9.62036 13.1506 9.8089 13.4059C9.99743 13.6612 10.0536 13.9908 9.96028 14.2941L9.01131 17.3782C8.6661 18.5002 9.35608 19.6596 10.4726 19.9153L13.6401 14.3721C13.9523 13.8258 14.4376 13.4141 15.0045 13.1902V5C15.0045 4.44772 14.5568 4 14.0045 4ZM17.0045 13V5C17.0045 4.64937 16.9444 4.31278 16.8338 4H18.0045C18.5568 4 19.0045 4.44772 19.0045 5V12C19.0045 12.5523 18.5568 13 18.0045 13H17.0045Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
39
client/src/components/svg/ThumbUpIcon.tsx
Normal file
39
client/src/components/svg/ThumbUpIcon.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { cn } from '~/utils';
|
||||
|
||||
export default function ThumbUpIcon({ className = '', size = '1em', bold = false }) {
|
||||
return bold ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={size}
|
||||
width={size}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M12.592 2.50386C12.8047 2.13014 13.2317 1.935 13.652 2.01942C15.5627 2.40314 16.7246 4.36079 16.1516 6.23085L15.303 9L17.0142 9C19.6409 9 21.5485 11.5079 20.8574 14.0525L19.4994 19.0525C19.0267 20.7927 17.4526 22 15.6562 22H9.96721C8.869 21.9979 7.97939 21.1033 7.97939 20V9H8.31734C8.67472 9 9.0047 8.80771 9.18201 8.49613L12.592 2.50386Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
<path
|
||||
d="M5.98763 9C4.33761 9 3 10.3431 3 12V19C3 20.6569 4.33761 22 5.98763 22H6.52055C6.18162 21.4116 5.98763 20.7286 5.98763 20V9Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={size}
|
||||
width={size}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.1318 2.50389C12.3321 2.15338 12.7235 1.95768 13.124 2.00775L13.5778 2.06447C16.0449 2.37286 17.636 4.83353 16.9048 7.20993L16.354 8.99999H17.0722C19.7097 8.99999 21.6253 11.5079 20.9313 14.0525L19.5677 19.0525C19.0931 20.7927 17.5124 22 15.7086 22H6C4.34315 22 3 20.6568 3 19V12C3 10.3431 4.34315 8.99999 6 8.99999H8C8.25952 8.99999 8.49914 8.86094 8.6279 8.63561L12.1318 2.50389ZM10 20H15.7086C16.6105 20 17.4008 19.3964 17.6381 18.5262L19.0018 13.5262C19.3488 12.2539 18.391 11 17.0722 11H15C14.6827 11 14.3841 10.8494 14.1956 10.5941C14.0071 10.3388 13.9509 10.0092 14.0442 9.70591L14.9932 6.62175C15.3384 5.49984 14.6484 4.34036 13.5319 4.08468L10.3644 9.62789C10.0522 10.1742 9.56691 10.5859 9 10.8098V19C9 19.5523 9.44772 20 10 20ZM7 11V19C7 19.3506 7.06015 19.6872 7.17071 20H6C5.44772 20 5 19.5523 5 19V12C5 11.4477 5.44772 11 6 11H7Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -58,4 +58,6 @@ export { default as SpeechIcon } from './SpeechIcon';
|
|||
export { default as SaveIcon } from './SaveIcon';
|
||||
export { default as CircleHelpIcon } from './CircleHelpIcon';
|
||||
export { default as BedrockIcon } from './BedrockIcon';
|
||||
export { default as ThumbUpIcon } from './ThumbUpIcon';
|
||||
export { default as ThumbDownIcon } from './ThumbDownIcon';
|
||||
export { default as XAIcon } from './XAIcon';
|
||||
|
|
|
|||
|
|
@ -113,11 +113,11 @@ export const useEndpoints = ({
|
|||
hasModels,
|
||||
icon: Icon
|
||||
? React.createElement(Icon, {
|
||||
size: 20,
|
||||
className: 'text-text-primary shrink-0 icon-md',
|
||||
iconURL: endpointIconURL,
|
||||
endpoint: ep,
|
||||
})
|
||||
size: 20,
|
||||
className: 'text-text-primary shrink-0 icon-md',
|
||||
iconURL: endpointIconURL,
|
||||
endpoint: ep,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
|
||||
import type { SearchResultData } from 'librechat-data-provider';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useUpdateFeedbackMutation } from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
isAssistantsEndpoint,
|
||||
isAgentsEndpoint,
|
||||
TUpdateFeedbackRequest,
|
||||
getTagByKey,
|
||||
TFeedback,
|
||||
toMinimalFeedback,
|
||||
SearchResultData,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import {
|
||||
useChatContext,
|
||||
|
|
@ -11,7 +19,7 @@ import {
|
|||
} from '~/Providers';
|
||||
import useCopyToClipboard from './useCopyToClipboard';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export type TMessageActions = Pick<
|
||||
|
|
@ -50,6 +58,18 @@ export default function useMessageActions(props: TMessageActions) {
|
|||
const { text, content, messageId = null, isCreatedByUser } = message ?? {};
|
||||
const edit = useMemo(() => messageId === currentEditId, [messageId, currentEditId]);
|
||||
|
||||
const [feedback, setFeedback] = useState<TFeedback | undefined>(() => {
|
||||
if (message?.feedback) {
|
||||
const tag = getTagByKey(message.feedback?.tag?.key);
|
||||
return {
|
||||
rating: message.feedback.rating,
|
||||
tag,
|
||||
text: message.feedback.text,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const enterEdit = useCallback(
|
||||
(cancel?: boolean) => setCurrentEditId && setCurrentEditId(cancel === true ? -1 : messageId),
|
||||
[messageId, setCurrentEditId],
|
||||
|
|
@ -113,6 +133,38 @@ export default function useMessageActions(props: TMessageActions) {
|
|||
}
|
||||
}, [message, agent, assistant, UsernameDisplay, user, localize]);
|
||||
|
||||
const feedbackMutation = useUpdateFeedbackMutation(
|
||||
conversation?.conversationId || '',
|
||||
message?.messageId || '',
|
||||
);
|
||||
|
||||
const handleFeedback = useCallback(
|
||||
({ feedback: newFeedback }: { feedback: TFeedback | undefined }) => {
|
||||
const payload: TUpdateFeedbackRequest = {
|
||||
feedback: newFeedback ? toMinimalFeedback(newFeedback) : undefined,
|
||||
};
|
||||
|
||||
feedbackMutation.mutate(payload, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.feedback) {
|
||||
setFeedback(undefined);
|
||||
} else {
|
||||
const tag = getTagByKey(data.feedback?.tag ?? undefined);
|
||||
setFeedback({
|
||||
rating: data.feedback.rating,
|
||||
tag,
|
||||
text: data.feedback.text,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update feedback:', error);
|
||||
},
|
||||
});
|
||||
},
|
||||
[feedbackMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
ask,
|
||||
edit,
|
||||
|
|
@ -128,5 +180,7 @@ export default function useMessageActions(props: TMessageActions) {
|
|||
copyToClipboard,
|
||||
setLatestMessage,
|
||||
regenerateMessage,
|
||||
handleFeedback,
|
||||
feedback,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -698,4 +698,4 @@
|
|||
"com_ui_zoom": "تكبير",
|
||||
"com_user_message": "أنت",
|
||||
"com_warning_resubmit_unsupported": "إعادة إرسال رسالة الذكاء الاصطناعي غير مدعومة لنقطة النهاية هذه"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -723,4 +723,4 @@
|
|||
"com_ui_zoom": "Přiblížit",
|
||||
"com_user_message": "Vy",
|
||||
"com_warning_resubmit_unsupported": "Opětovné odeslání AI zprávy není pro tento koncový bod podporováno."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -922,4 +922,4 @@
|
|||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "Du",
|
||||
"com_warning_resubmit_unsupported": "Das erneute Senden der KI-Nachricht wird für diesen Endpunkt nicht unterstützt."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -715,8 +715,6 @@
|
|||
"com_ui_fork_info_visible": "This option forks only the visible messages; in other words, the direct path to the target message, without any branches.",
|
||||
"com_ui_fork_more_details_about": "View additional information and details about the \"{{0}}\" fork option",
|
||||
"com_ui_fork_more_info_options": "View detailed explanation of all fork options and their behaviors",
|
||||
"com_ui_fork_more_info_remember": "View explanation of how the \"{{0}}\" option saves your preferences for future forks",
|
||||
"com_ui_fork_more_info_split_target": "View explanation of how the \"{{0}}\" option affects which messages are included in your fork",
|
||||
"com_ui_fork_processing": "Forking conversation...",
|
||||
"com_ui_fork_remember": "Remember",
|
||||
"com_ui_fork_remember_checked": "Your selection will be remembered after usage. Change this at any time in the settings.",
|
||||
|
|
@ -922,6 +920,22 @@
|
|||
"com_ui_version_var": "Version {{0}}",
|
||||
"com_ui_versions": "Versions",
|
||||
"com_ui_view_source": "View source chat",
|
||||
"com_ui_feedback_more_information": "Provide additional feedback",
|
||||
"com_ui_feedback_positive": "Love this",
|
||||
"com_ui_feedback_negative": "Needs improvement",
|
||||
"com_ui_feedback_more": "More...",
|
||||
"com_ui_feedback_placeholder": "Please provide any additional feedback here",
|
||||
"com_ui_feedback_tag_not_matched": "Didn't match my request",
|
||||
"com_ui_feedback_tag_inaccurate": "Inaccurate or incorrect answer",
|
||||
"com_ui_feedback_tag_bad_style": "Poor style or tone",
|
||||
"com_ui_feedback_tag_missing_image": "Expected an image",
|
||||
"com_ui_feedback_tag_unjustified_refusal": "Refused without reason",
|
||||
"com_ui_feedback_tag_not_helpful": "Lacked useful information",
|
||||
"com_ui_feedback_tag_other": "Other issue",
|
||||
"com_ui_feedback_tag_accurate_reliable": "Accurate and Reliable",
|
||||
"com_ui_feedback_tag_creative_solution": "Creative Solution",
|
||||
"com_ui_feedback_tag_clear_well_written": "Clear and Well-Written",
|
||||
"com_ui_feedback_tag_attention_to_detail": "Attention to Detail",
|
||||
"com_ui_web_search": "Web Search",
|
||||
"com_ui_web_search_api_subtitle": "Search the web for up-to-date information",
|
||||
"com_ui_web_search_cohere_key": "Enter Cohere API Key",
|
||||
|
|
|
|||
|
|
@ -755,4 +755,4 @@
|
|||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "Usted",
|
||||
"com_warning_resubmit_unsupported": "No se admite el reenvío del mensaje de IA para este punto de conexión."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -870,4 +870,4 @@
|
|||
"com_ui_zoom": "Suumi",
|
||||
"com_user_message": "Sina",
|
||||
"com_warning_resubmit_unsupported": "AI sõnumi uuesti esitamine pole selle otspunkti jaoks toetatud."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -850,4 +850,4 @@
|
|||
"com_ui_zoom": "بزرگنمایی ضربه بزنید؛",
|
||||
"com_user_message": "شما",
|
||||
"com_warning_resubmit_unsupported": "ارسال مجدد پیام هوش مصنوعی برای این نقطه پایانی پشتیبانی نمی شود."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -547,4 +547,4 @@
|
|||
"com_ui_versions": "Versiot",
|
||||
"com_ui_yes": "Kyllä",
|
||||
"com_user_message": "Sinä"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -755,4 +755,4 @@
|
|||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "Vous",
|
||||
"com_warning_resubmit_unsupported": "La resoumission du message IA n'est pas prise en charge pour ce point de terminaison."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -868,4 +868,4 @@
|
|||
"com_ui_zoom": "זום",
|
||||
"com_user_message": "אתה",
|
||||
"com_warning_resubmit_unsupported": "שליחת הודעה מחדש אינה נתמכת עבור נקודת קצה זו."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -850,4 +850,4 @@
|
|||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "Ön",
|
||||
"com_warning_resubmit_unsupported": "Az AI üzenet újraküldése nem támogatott ennél a végpontnál."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,4 +290,4 @@
|
|||
"com_ui_upload_success": "Berhasil mengunggah file",
|
||||
"com_ui_use_prompt": "Gunakan petunjuk",
|
||||
"com_user_message": "Kamu"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -832,4 +832,4 @@
|
|||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "Mostra nome utente nei messaggi",
|
||||
"com_warning_resubmit_unsupported": "Il reinvio del messaggio AI non è supportato per questo endpoint."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -873,4 +873,4 @@
|
|||
"com_ui_zoom": "ズーム",
|
||||
"com_user_message": "あなた",
|
||||
"com_warning_resubmit_unsupported": "このエンドポイントではAIメッセージの再送信はサポートされていません"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -926,4 +926,4 @@
|
|||
"com_ui_zoom": "확대/축소",
|
||||
"com_user_message": "당신",
|
||||
"com_warning_resubmit_unsupported": "이 엔드포인트에서는 AI 메시지 재전송이 지원되지 않습니다"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -310,4 +310,4 @@
|
|||
"com_ui_unarchive_error": "Kan conversatie niet uit archiveren",
|
||||
"com_ui_upload_success": "Bestand succesvol geüpload",
|
||||
"com_ui_use_prompt": "Gebruik prompt"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -717,4 +717,4 @@
|
|||
"com_ui_zoom": "Powiększ",
|
||||
"com_user_message": "Ty",
|
||||
"com_warning_resubmit_unsupported": "Ponowne przesyłanie wiadomości AI nie jest obsługiwane dla tego punktu końcowego."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -820,4 +820,4 @@
|
|||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "Você",
|
||||
"com_warning_resubmit_unsupported": "O reenvio da mensagem de IA não é suportado para este endpoint."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -822,4 +822,4 @@
|
|||
"com_ui_zoom": "Ampliar",
|
||||
"com_user_message": "Você",
|
||||
"com_warning_resubmit_unsupported": "O reenvio da mensagem de IA não é suportado por este endereço."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -870,4 +870,4 @@
|
|||
"com_ui_zoom": "Масштаб",
|
||||
"com_user_message": "Вы",
|
||||
"com_warning_resubmit_unsupported": "Повторная отправка сообщения ИИ не поддерживается для данной конечной точки"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -282,4 +282,4 @@
|
|||
"com_ui_unarchive_error": "Kunde inte avarkivera chatt",
|
||||
"com_ui_upload_success": "Uppladdningen av filen lyckades",
|
||||
"com_ui_use_prompt": "Använd prompt"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -805,4 +805,4 @@
|
|||
"com_ui_zoom": "ขยาย",
|
||||
"com_user_message": "คุณ",
|
||||
"com_warning_resubmit_unsupported": "การส่งข้อความ AI ซ้ำไม่รองรับสำหรับจุดสิ้นสุดนี้"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -728,4 +728,4 @@
|
|||
"com_ui_zoom": "Yakınlaştır",
|
||||
"com_user_message": "Sen",
|
||||
"com_warning_resubmit_unsupported": "Bu uç nokta için yapay zeka mesajını yeniden gönderme desteklenmiyor."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -855,4 +855,4 @@
|
|||
"com_ui_zoom": "缩放",
|
||||
"com_user_message": "您",
|
||||
"com_warning_resubmit_unsupported": "此终端不支持重新提交AI消息"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -698,4 +698,4 @@
|
|||
"com_ui_zoom": "縮放",
|
||||
"com_user_message": "您",
|
||||
"com_warning_resubmit_unsupported": "此端點不支援重新送出 AI 訊息。"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2548,6 +2548,18 @@ html {
|
|||
margin-right: -2px;
|
||||
}
|
||||
|
||||
.popover-animate {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-0.5rem);
|
||||
transition:
|
||||
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.popover-animate[data-enter] {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
.popover-ui:focus-visible,
|
||||
.popover-ui[data-focus-visible] {
|
||||
outline: var(--bg-surface-hover);
|
||||
|
|
|
|||
|
|
@ -275,8 +275,7 @@ describe('ActionRequest', () => {
|
|||
expect(config?.headers).toEqual({
|
||||
'some-header': 'header-var',
|
||||
});
|
||||
expect(config?.params).toEqual({
|
||||
});
|
||||
expect(config?.params).toEqual({});
|
||||
expect(response.data.success).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -285,13 +284,13 @@ describe('ActionRequest', () => {
|
|||
|
||||
const data: Record<string, unknown> = {
|
||||
'api-version': '2025-01-01',
|
||||
'message': 'a body parameter',
|
||||
message: 'a body parameter',
|
||||
'some-header': 'header-var',
|
||||
};
|
||||
|
||||
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
||||
'api-version': 'query',
|
||||
'message': 'body',
|
||||
message: 'body',
|
||||
'some-header': 'header',
|
||||
};
|
||||
|
||||
|
|
@ -326,13 +325,13 @@ describe('ActionRequest', () => {
|
|||
|
||||
const data: Record<string, unknown> = {
|
||||
'api-version': '2025-01-01',
|
||||
'message': 'a body parameter',
|
||||
message: 'a body parameter',
|
||||
'some-header': 'header-var',
|
||||
};
|
||||
|
||||
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
||||
'api-version': 'query',
|
||||
'message': 'body',
|
||||
message: 'body',
|
||||
'some-header': 'header',
|
||||
};
|
||||
|
||||
|
|
@ -367,13 +366,13 @@ describe('ActionRequest', () => {
|
|||
|
||||
const data: Record<string, unknown> = {
|
||||
'api-version': '2025-01-01',
|
||||
'message': 'a body parameter',
|
||||
message: 'a body parameter',
|
||||
'some-header': 'header-var',
|
||||
};
|
||||
|
||||
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
||||
'api-version': 'query',
|
||||
'message': 'body',
|
||||
message: 'body',
|
||||
'some-header': 'header',
|
||||
};
|
||||
|
||||
|
|
@ -443,7 +442,6 @@ describe('ActionRequest', () => {
|
|||
});
|
||||
expect(response.data.success).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it('throws an error for unsupported HTTP method', async () => {
|
||||
|
|
|
|||
|
|
@ -303,7 +303,8 @@ class RequestExecutor {
|
|||
if (this.config.parameterLocations && this.params) {
|
||||
for (const key of Object.keys(this.params)) {
|
||||
// Determine parameter placement; default to "query" for GET and "body" for others.
|
||||
const loc: 'query' | 'path' | 'header' | 'body' = this.config.parameterLocations[key] || (method === 'get' ? 'query' : 'body');
|
||||
const loc: 'query' | 'path' | 'header' | 'body' =
|
||||
this.config.parameterLocations[key] || (method === 'get' ? 'query' : 'body');
|
||||
|
||||
const val = this.params[key];
|
||||
if (loc === 'query') {
|
||||
|
|
@ -351,7 +352,15 @@ export class ActionRequest {
|
|||
contentType: string,
|
||||
parameterLocations?: Record<string, 'query' | 'path' | 'header' | 'body'>,
|
||||
) {
|
||||
this.config = new RequestConfig(domain, path, method, operation, isConsequential, contentType, parameterLocations);
|
||||
this.config = new RequestConfig(
|
||||
domain,
|
||||
path,
|
||||
method,
|
||||
operation,
|
||||
isConsequential,
|
||||
contentType,
|
||||
parameterLocations,
|
||||
);
|
||||
}
|
||||
|
||||
// Add getters to maintain backward compatibility
|
||||
|
|
@ -486,12 +495,12 @@ export function openapiToFunction(
|
|||
}
|
||||
// Record the parameter location from the OpenAPI "in" field.
|
||||
paramLocations[paramName] =
|
||||
(resolvedParam.in === 'query' ||
|
||||
resolvedParam.in === 'path' ||
|
||||
resolvedParam.in === 'header' ||
|
||||
resolvedParam.in === 'body')
|
||||
? resolvedParam.in
|
||||
: 'query';
|
||||
resolvedParam.in === 'query' ||
|
||||
resolvedParam.in === 'path' ||
|
||||
resolvedParam.in === 'header' ||
|
||||
resolvedParam.in === 'body'
|
||||
? resolvedParam.in
|
||||
: 'query';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -272,6 +272,10 @@ export const userTerms = () => '/api/user/terms';
|
|||
export const acceptUserTerms = () => '/api/user/terms/accept';
|
||||
export const banner = () => '/api/banner';
|
||||
|
||||
// Message Feedback
|
||||
export const feedback = (conversationId: string, messageId: string) =>
|
||||
`/api/messages/${conversationId}/${messageId}/feedback`;
|
||||
|
||||
// Two-Factor Endpoints
|
||||
export const enableTwoFactor = () => '/api/auth/2fa/enable';
|
||||
export const verifyTwoFactor = () => '/api/auth/2fa/verify';
|
||||
|
|
|
|||
|
|
@ -765,6 +765,15 @@ export function getBanner(): Promise<t.TBannerResponse> {
|
|||
return request.get(endpoints.banner());
|
||||
}
|
||||
|
||||
export function updateFeedback(
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
payload: t.TUpdateFeedbackRequest,
|
||||
): Promise<t.TUpdateFeedbackResponse> {
|
||||
return request.put(endpoints.feedback(conversationId, messageId), payload);
|
||||
}
|
||||
|
||||
// 2FA
|
||||
export function enableTwoFactor(): Promise<t.TEnable2FAResponse> {
|
||||
return request.get(endpoints.enableTwoFactor());
|
||||
}
|
||||
|
|
|
|||
141
packages/data-provider/src/feedback.ts
Normal file
141
packages/data-provider/src/feedback.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export type TFeedbackRating = 'thumbsUp' | 'thumbsDown';
|
||||
export const FEEDBACK_RATINGS = ['thumbsUp', 'thumbsDown'] as const;
|
||||
|
||||
export const FEEDBACK_REASON_KEYS = [
|
||||
// Down
|
||||
'not_matched',
|
||||
'inaccurate',
|
||||
'bad_style',
|
||||
'missing_image',
|
||||
'unjustified_refusal',
|
||||
'not_helpful',
|
||||
'other',
|
||||
// Up
|
||||
'accurate_reliable',
|
||||
'creative_solution',
|
||||
'clear_well_written',
|
||||
'attention_to_detail',
|
||||
] as const;
|
||||
|
||||
export type TFeedbackTagKey = (typeof FEEDBACK_REASON_KEYS)[number];
|
||||
|
||||
export interface TFeedbackTag {
|
||||
key: TFeedbackTagKey;
|
||||
label: string;
|
||||
direction: TFeedbackRating;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// --- Tag Registry ---
|
||||
export const FEEDBACK_TAGS: TFeedbackTag[] = [
|
||||
// Thumbs Down
|
||||
{
|
||||
key: 'not_matched',
|
||||
label: 'com_ui_feedback_tag_not_matched',
|
||||
direction: 'thumbsDown',
|
||||
icon: 'AlertCircle',
|
||||
},
|
||||
{
|
||||
key: 'inaccurate',
|
||||
label: 'com_ui_feedback_tag_inaccurate',
|
||||
direction: 'thumbsDown',
|
||||
icon: 'AlertCircle',
|
||||
},
|
||||
{
|
||||
key: 'bad_style',
|
||||
label: 'com_ui_feedback_tag_bad_style',
|
||||
direction: 'thumbsDown',
|
||||
icon: 'PenTool',
|
||||
},
|
||||
{
|
||||
key: 'missing_image',
|
||||
label: 'com_ui_feedback_tag_missing_image',
|
||||
direction: 'thumbsDown',
|
||||
icon: 'ImageOff',
|
||||
},
|
||||
{
|
||||
key: 'unjustified_refusal',
|
||||
label: 'com_ui_feedback_tag_unjustified_refusal',
|
||||
direction: 'thumbsDown',
|
||||
icon: 'Ban',
|
||||
},
|
||||
{
|
||||
key: 'not_helpful',
|
||||
label: 'com_ui_feedback_tag_not_helpful',
|
||||
direction: 'thumbsDown',
|
||||
icon: 'ThumbsDown',
|
||||
},
|
||||
{
|
||||
key: 'other',
|
||||
label: 'com_ui_feedback_tag_other',
|
||||
direction: 'thumbsDown',
|
||||
icon: 'HelpCircle',
|
||||
},
|
||||
// Thumbs Up
|
||||
{
|
||||
key: 'accurate_reliable',
|
||||
label: 'com_ui_feedback_tag_accurate_reliable',
|
||||
direction: 'thumbsUp',
|
||||
icon: 'CheckCircle',
|
||||
},
|
||||
{
|
||||
key: 'creative_solution',
|
||||
label: 'com_ui_feedback_tag_creative_solution',
|
||||
direction: 'thumbsUp',
|
||||
icon: 'Lightbulb',
|
||||
},
|
||||
{
|
||||
key: 'clear_well_written',
|
||||
label: 'com_ui_feedback_tag_clear_well_written',
|
||||
direction: 'thumbsUp',
|
||||
icon: 'PenTool',
|
||||
},
|
||||
{
|
||||
key: 'attention_to_detail',
|
||||
label: 'com_ui_feedback_tag_attention_to_detail',
|
||||
direction: 'thumbsUp',
|
||||
icon: 'Search',
|
||||
},
|
||||
];
|
||||
|
||||
export function getTagsForRating(rating: TFeedbackRating): TFeedbackTag[] {
|
||||
return FEEDBACK_TAGS.filter((tag) => tag.direction === rating);
|
||||
}
|
||||
|
||||
export const feedbackTagKeySchema = z.enum(FEEDBACK_REASON_KEYS);
|
||||
export const feedbackRatingSchema = z.enum(FEEDBACK_RATINGS);
|
||||
|
||||
export const feedbackSchema = z.object({
|
||||
rating: feedbackRatingSchema,
|
||||
tag: feedbackTagKeySchema,
|
||||
text: z.string().max(1024).optional(),
|
||||
});
|
||||
|
||||
export type TMinimalFeedback = z.infer<typeof feedbackSchema>;
|
||||
|
||||
export type TFeedback = {
|
||||
rating: TFeedbackRating;
|
||||
tag: TFeedbackTag | undefined;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
export function toMinimalFeedback(feedback: TFeedback | undefined): TMinimalFeedback | undefined {
|
||||
if (!feedback?.rating || !feedback?.tag || !feedback.tag.key) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
rating: feedback.rating,
|
||||
tag: feedback.tag.key,
|
||||
text: feedback.text,
|
||||
};
|
||||
}
|
||||
|
||||
export function getTagByKey(key: TFeedbackTagKey | undefined): TFeedbackTag | undefined {
|
||||
if (!key) {
|
||||
return undefined;
|
||||
}
|
||||
return FEEDBACK_TAGS.find((tag) => tag.key === key);
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
/* eslint-disable max-len */
|
||||
import { z } from 'zod';
|
||||
import { EModelEndpoint } from './schemas';
|
||||
import type { FileConfig, EndpointFileConfig } from './types/files';
|
||||
|
|
|
|||
|
|
@ -39,4 +39,6 @@ import * as dataService from './data-service';
|
|||
export * from './utils';
|
||||
export * from './actions';
|
||||
export { default as createPayload } from './createPayload';
|
||||
/* feedback */
|
||||
export * from './feedback';
|
||||
export * from './parameterSettings';
|
||||
|
|
|
|||
|
|
@ -347,3 +347,19 @@ export const useGetCustomConfigSpeechQuery = (
|
|||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useUpdateFeedbackMutation = (
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
): UseMutationResult<t.TUpdateFeedbackResponse, Error, t.TUpdateFeedbackRequest> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(payload: t.TUpdateFeedbackRequest) =>
|
||||
dataService.updateFeedback(conversationId, messageId, payload),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries([QueryKeys.messages, messageId]);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
import { Tools } from './types/assistants';
|
||||
import type { TMessageContentParts, FunctionTool, FunctionToolCall } from './types/assistants';
|
||||
import { TFeedback, feedbackSchema } from './feedback';
|
||||
import type { SearchResultData } from './types/web';
|
||||
import type { TEphemeralAgent } from './types';
|
||||
import type { TFile } from './types/files';
|
||||
|
|
@ -518,6 +519,7 @@ export const tMessageSchema = z.object({
|
|||
thread_id: z.string().optional(),
|
||||
/* frontend components */
|
||||
iconURL: z.string().nullable().optional(),
|
||||
feedback: feedbackSchema.optional(),
|
||||
});
|
||||
|
||||
export type TAttachmentMetadata = {
|
||||
|
|
@ -543,6 +545,7 @@ export type TMessage = z.input<typeof tMessageSchema> & {
|
|||
siblingIndex?: number;
|
||||
attachments?: TAttachment[];
|
||||
clientTimestamp?: string;
|
||||
feedback?: TFeedback;
|
||||
};
|
||||
|
||||
export const coerceNumber = z.union([z.number(), z.string()]).transform((val) => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import type {
|
|||
TConversationTag,
|
||||
TBanner,
|
||||
} from './schemas';
|
||||
import { TMinimalFeedback } from './feedback';
|
||||
import { SettingDefinition } from './generate';
|
||||
|
||||
export type TOpenAIMessage = OpenAI.Chat.ChatCompletionMessageParam;
|
||||
|
||||
export * from './schemas';
|
||||
|
|
@ -547,6 +549,16 @@ export type TAcceptTermsResponse = {
|
|||
|
||||
export type TBannerResponse = TBanner | null;
|
||||
|
||||
export type TUpdateFeedbackRequest = {
|
||||
feedback?: TMinimalFeedback;
|
||||
};
|
||||
|
||||
export type TUpdateFeedbackResponse = {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
feedback?: TMinimalFeedback;
|
||||
}
|
||||
|
||||
export type TBalanceResponse = {
|
||||
tokenCredits: number;
|
||||
// Automatic refill settings
|
||||
|
|
|
|||
|
|
@ -264,19 +264,19 @@ describe('convertJsonSchemaToZod', () => {
|
|||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'The user\'s name',
|
||||
description: "The user's name",
|
||||
},
|
||||
age: {
|
||||
type: 'number',
|
||||
description: 'The user\'s age',
|
||||
description: "The user's age",
|
||||
},
|
||||
},
|
||||
};
|
||||
const zodSchema = convertJsonSchemaToZod(schema);
|
||||
|
||||
const shape = (zodSchema as z.ZodObject<any>).shape;
|
||||
expect(shape.name.description).toBe('The user\'s name');
|
||||
expect(shape.age.description).toBe('The user\'s age');
|
||||
expect(shape.name.description).toBe("The user's name");
|
||||
expect(shape.age.description).toBe("The user's age");
|
||||
});
|
||||
|
||||
it('should preserve descriptions in nested objects', () => {
|
||||
|
|
@ -290,7 +290,7 @@ describe('convertJsonSchemaToZod', () => {
|
|||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'The user\'s name',
|
||||
description: "The user's name",
|
||||
},
|
||||
settings: {
|
||||
type: 'object',
|
||||
|
|
@ -318,7 +318,7 @@ describe('convertJsonSchemaToZod', () => {
|
|||
|
||||
const userShape = shape.user instanceof z.ZodObject ? shape.user.shape : {};
|
||||
if ('name' in userShape && 'settings' in userShape) {
|
||||
expect(userShape.name.description).toBe('The user\'s name');
|
||||
expect(userShape.name.description).toBe("The user's name");
|
||||
expect(userShape.settings.description).toBe('User preferences');
|
||||
|
||||
const settingsShape =
|
||||
|
|
@ -682,10 +682,7 @@ describe('convertJsonSchemaToZod', () => {
|
|||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['name'] },
|
||||
{ required: ['age'] },
|
||||
],
|
||||
anyOf: [{ required: ['name'] }, { required: ['age'] }],
|
||||
oneOf: [
|
||||
{ properties: { role: { type: 'string', enum: ['admin'] } } },
|
||||
{ properties: { role: { type: 'string', enum: ['user'] } } },
|
||||
|
|
@ -708,7 +705,7 @@ describe('convertJsonSchemaToZod', () => {
|
|||
it('should drop fields from nested schemas', () => {
|
||||
// Create a schema with nested fields that should be dropped
|
||||
const schema: JsonSchemaType & {
|
||||
properties?: Record<string, JsonSchemaType & { anyOf?: any; oneOf?: any }>
|
||||
properties?: Record<string, JsonSchemaType & { anyOf?: any; oneOf?: any }>;
|
||||
} = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
@ -718,10 +715,7 @@ describe('convertJsonSchemaToZod', () => {
|
|||
name: { type: 'string' },
|
||||
role: { type: 'string' },
|
||||
},
|
||||
anyOf: [
|
||||
{ required: ['name'] },
|
||||
{ required: ['role'] },
|
||||
],
|
||||
anyOf: [{ required: ['name'] }, { required: ['role'] }],
|
||||
},
|
||||
settings: {
|
||||
type: 'object',
|
||||
|
|
@ -742,20 +736,24 @@ describe('convertJsonSchemaToZod', () => {
|
|||
});
|
||||
|
||||
// The schema should still validate normal properties
|
||||
expect(zodSchema?.parse({
|
||||
user: { name: 'John', role: 'admin' },
|
||||
settings: { theme: 'custom' }, // This would fail if oneOf was still present
|
||||
})).toEqual({
|
||||
expect(
|
||||
zodSchema?.parse({
|
||||
user: { name: 'John', role: 'admin' },
|
||||
settings: { theme: 'custom' }, // This would fail if oneOf was still present
|
||||
}),
|
||||
).toEqual({
|
||||
user: { name: 'John', role: 'admin' },
|
||||
settings: { theme: 'custom' },
|
||||
});
|
||||
|
||||
// But the anyOf constraint should be gone from user
|
||||
// (If it was present, this would fail because neither name nor role is required)
|
||||
expect(zodSchema?.parse({
|
||||
user: {},
|
||||
settings: { theme: 'light' },
|
||||
})).toEqual({
|
||||
expect(
|
||||
zodSchema?.parse({
|
||||
user: {},
|
||||
settings: { theme: 'light' },
|
||||
}),
|
||||
).toEqual({
|
||||
user: {},
|
||||
settings: { theme: 'light' },
|
||||
});
|
||||
|
|
@ -803,10 +801,7 @@ describe('convertJsonSchemaToZod', () => {
|
|||
anyOf: [{ minItems: 1 }],
|
||||
},
|
||||
},
|
||||
oneOf: [
|
||||
{ required: ['name', 'permissions'] },
|
||||
{ required: ['name'] },
|
||||
],
|
||||
oneOf: [{ required: ['name', 'permissions'] }, { required: ['name'] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -871,10 +866,7 @@ describe('convertJsonSchemaToZod', () => {
|
|||
const schema = {
|
||||
type: 'object', // Add a type to satisfy JsonSchemaType
|
||||
properties: {}, // Empty properties
|
||||
oneOf: [
|
||||
{ type: 'string' },
|
||||
{ type: 'number' },
|
||||
],
|
||||
oneOf: [{ type: 'string' }, { type: 'number' }],
|
||||
} as JsonSchemaType & { oneOf?: any };
|
||||
|
||||
// Convert with transformOneOfAnyOf option
|
||||
|
|
@ -893,10 +885,7 @@ describe('convertJsonSchemaToZod', () => {
|
|||
const schema = {
|
||||
type: 'object', // Add a type to satisfy JsonSchemaType
|
||||
properties: {}, // Empty properties
|
||||
anyOf: [
|
||||
{ type: 'string' },
|
||||
{ type: 'number' },
|
||||
],
|
||||
anyOf: [{ type: 'string' }, { type: 'number' }],
|
||||
} as JsonSchemaType & { anyOf?: any };
|
||||
|
||||
// Convert with transformOneOfAnyOf option
|
||||
|
|
@ -956,10 +945,7 @@ describe('convertJsonSchemaToZod', () => {
|
|||
properties: {
|
||||
value: { type: 'string' },
|
||||
},
|
||||
oneOf: [
|
||||
{ required: ['value'] },
|
||||
{ properties: { optional: { type: 'boolean' } } },
|
||||
],
|
||||
oneOf: [{ required: ['value'] }, { properties: { optional: { type: 'boolean' } } }],
|
||||
} as JsonSchemaType & { oneOf?: any };
|
||||
|
||||
// Convert with transformOneOfAnyOf option
|
||||
|
|
@ -1013,9 +999,12 @@ describe('convertJsonSchemaToZod', () => {
|
|||
},
|
||||
},
|
||||
} as JsonSchemaType & {
|
||||
properties?: Record<string, JsonSchemaType & {
|
||||
properties?: Record<string, JsonSchemaType & { oneOf?: any }>
|
||||
}>
|
||||
properties?: Record<
|
||||
string,
|
||||
JsonSchemaType & {
|
||||
properties?: Record<string, JsonSchemaType & { oneOf?: any }>;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
// Convert with transformOneOfAnyOf option
|
||||
|
|
@ -1024,14 +1013,16 @@ describe('convertJsonSchemaToZod', () => {
|
|||
});
|
||||
|
||||
// The schema should validate nested unions
|
||||
expect(zodSchema?.parse({
|
||||
user: {
|
||||
contact: {
|
||||
type: 'email',
|
||||
email: 'test@example.com',
|
||||
expect(
|
||||
zodSchema?.parse({
|
||||
user: {
|
||||
contact: {
|
||||
type: 'email',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
})).toEqual({
|
||||
}),
|
||||
).toEqual({
|
||||
user: {
|
||||
contact: {
|
||||
type: 'email',
|
||||
|
|
@ -1040,14 +1031,16 @@ describe('convertJsonSchemaToZod', () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(zodSchema?.parse({
|
||||
user: {
|
||||
contact: {
|
||||
type: 'phone',
|
||||
phone: '123-456-7890',
|
||||
expect(
|
||||
zodSchema?.parse({
|
||||
user: {
|
||||
contact: {
|
||||
type: 'phone',
|
||||
phone: '123-456-7890',
|
||||
},
|
||||
},
|
||||
},
|
||||
})).toEqual({
|
||||
}),
|
||||
).toEqual({
|
||||
user: {
|
||||
contact: {
|
||||
type: 'phone',
|
||||
|
|
@ -1057,14 +1050,16 @@ describe('convertJsonSchemaToZod', () => {
|
|||
});
|
||||
|
||||
// Should reject invalid contact types
|
||||
expect(() => zodSchema?.parse({
|
||||
user: {
|
||||
contact: {
|
||||
type: 'email',
|
||||
phone: '123-456-7890', // Missing email, has phone instead
|
||||
expect(() =>
|
||||
zodSchema?.parse({
|
||||
user: {
|
||||
contact: {
|
||||
type: 'email',
|
||||
phone: '123-456-7890', // Missing email, has phone instead
|
||||
},
|
||||
},
|
||||
},
|
||||
})).toThrow();
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should work with dropFields option', () => {
|
||||
|
|
@ -1072,10 +1067,7 @@ describe('convertJsonSchemaToZod', () => {
|
|||
const schema = {
|
||||
type: 'object', // Add a type to satisfy JsonSchemaType
|
||||
properties: {}, // Empty properties
|
||||
oneOf: [
|
||||
{ type: 'string' },
|
||||
{ type: 'number' },
|
||||
],
|
||||
oneOf: [{ type: 'string' }, { type: 'number' }],
|
||||
deprecated: true, // Field to drop
|
||||
} as JsonSchemaType & { oneOf?: any; deprecated?: boolean };
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
import { TFeedbackRating, TFeedbackTag } from 'librechat-data-provider';
|
||||
|
||||
// @ts-ignore
|
||||
export interface IMessage extends Document {
|
||||
|
|
@ -20,6 +21,11 @@ export interface IMessage extends Document {
|
|||
unfinished?: boolean;
|
||||
error?: boolean;
|
||||
finish_reason?: string;
|
||||
feedback?: {
|
||||
rating: TFeedbackRating;
|
||||
tag: TFeedbackTag | undefined;
|
||||
text?: string;
|
||||
};
|
||||
_meiliIndex?: boolean;
|
||||
files?: unknown[];
|
||||
plugin?: {
|
||||
|
|
@ -110,6 +116,25 @@ const messageSchema: Schema<IMessage> = new Schema(
|
|||
finish_reason: {
|
||||
type: String,
|
||||
},
|
||||
feedback: {
|
||||
type: {
|
||||
rating: {
|
||||
type: String,
|
||||
enum: ['thumbsUp', 'thumbsDown'],
|
||||
required: true,
|
||||
},
|
||||
tag: {
|
||||
type: mongoose.Schema.Types.Mixed,
|
||||
required: false,
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
default: undefined,
|
||||
required: false,
|
||||
},
|
||||
_meiliIndex: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue