📈 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:
Ruben Talstra 2025-05-30 18:16:34 +02:00 committed by GitHub
parent 4808c5be48
commit 4cbab86b45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 1592 additions and 835 deletions

View file

@ -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';
}
}

View file

@ -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';

View file

@ -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());
}

View 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);
}

View file

@ -1,4 +1,3 @@
/* eslint-disable max-len */
import { z } from 'zod';
import { EModelEndpoint } from './schemas';
import type { FileConfig, EndpointFileConfig } from './types/files';

View file

@ -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';

View file

@ -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]);
},
},
);
};

View file

@ -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) => {

View file

@ -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

View file

@ -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 };