From 109014beb46dacb1bf577ede396c9792e5c79ee4 Mon Sep 17 00:00:00 2001 From: suwhang-cisco Date: Fri, 7 Nov 2025 17:23:35 +0000 Subject: [PATCH 1/2] feat: user feedback thumbs up/down button Signed-off-by: suwhang-cisco --- .../plugins/agent-forge/config.d.ts | 12 + .../agent-forge/src/apis/ChatbotApi.ts | 61 +++- .../src/components/AgentForgePage.tsx | 99 ++++++- .../src/components/ChatContainer.tsx | 37 ++- .../src/components/ChatFeedback.tsx | 266 ++++++++++++++++++ .../src/components/ChatMessage.tsx | 164 ++++++++++- .../src/components/FeedbackButton.tsx | 106 +++++++ .../plugins/agent-forge/src/types.ts | 9 + 8 files changed, 742 insertions(+), 12 deletions(-) create mode 100644 workspaces/agent-forge/plugins/agent-forge/src/components/ChatFeedback.tsx create mode 100644 workspaces/agent-forge/plugins/agent-forge/src/components/FeedbackButton.tsx diff --git a/workspaces/agent-forge/plugins/agent-forge/config.d.ts b/workspaces/agent-forge/plugins/agent-forge/config.d.ts index c078418d40a..cf613562a29 100644 --- a/workspaces/agent-forge/plugins/agent-forge/config.d.ts +++ b/workspaces/agent-forge/plugins/agent-forge/config.d.ts @@ -75,6 +75,18 @@ export interface Config { */ useOpenIDToken?: boolean; + /** + * Whether to enable feedback (default: false) + * @visibility frontend + */ + enableFeedback?: boolean; + + /** + * The endpoint for submitting feedback (default: /submit_feedback) + * @visibility frontend + */ + feedbackEndpoint?: string; + /** * The header title to display (default: bot name) * @visibility frontend diff --git a/workspaces/agent-forge/plugins/agent-forge/src/apis/ChatbotApi.ts b/workspaces/agent-forge/plugins/agent-forge/src/apis/ChatbotApi.ts index 6bb61b18764..2b021df5ece 100644 --- a/workspaces/agent-forge/plugins/agent-forge/src/apis/ChatbotApi.ts +++ b/workspaces/agent-forge/plugins/agent-forge/src/apis/ChatbotApi.ts @@ -23,10 +23,14 @@ import { AgentCard, } from '../a2a/schema'; import { IdentityApi, OpenIdConnectApi } from '@backstage/core-plugin-api'; +import { Message, Feedback } from '../types'; +import { createTimestamp } from '../utils'; +import axios, { AxiosError } from 'axios'; export interface IChatbotApiOptions { requestTimeout?: number; useOpenIDToken?: boolean; + feedbackEndpoint?: string | null; } export class ChatbotApi { @@ -35,9 +39,13 @@ export class ChatbotApi { private identityApi: IdentityApi; private openIdConnectApi: OpenIdConnectApi | null; private useOpenIDToken: boolean; + private feedbackEndpoint: string | null; constructor( private apiBaseUrl: string, - options: { identityApi: IdentityApi; openIdConnectApi?: OpenIdConnectApi | null }, + options: { + identityApi: IdentityApi; + openIdConnectApi?: OpenIdConnectApi | null; + }, apiOptions?: IChatbotApiOptions, ) { this.contextId = ''; @@ -47,6 +55,7 @@ export class ChatbotApi { this.identityApi = options.identityApi; this.openIdConnectApi = options.openIdConnectApi ?? null; this.useOpenIDToken = apiOptions?.useOpenIDToken ?? false; // default to false which means use IdentityApi.getCredentials() (backstage token) + this.feedbackEndpoint = apiOptions?.feedbackEndpoint ?? null; try { const timeout = apiOptions?.requestTimeout ?? 300; // Default to 300 seconds this.client = new A2AClient(this.apiBaseUrl, timeout); @@ -58,7 +67,9 @@ export class ChatbotApi { private async getToken(): Promise { if (this.useOpenIDToken) { if (!this.openIdConnectApi) { - console.warn('useOpenIDToken is true but openIdConnectApi is not provided, falling back to IdentityApi'); + console.warn( + 'useOpenIDToken is true but openIdConnectApi is not provided, falling back to IdentityApi', + ); const credentials = await this.identityApi.getCredentials(); return credentials.token; } @@ -158,6 +169,52 @@ export class ChatbotApi { } } + public async submitFeedback( + message: Message, + feedback: Feedback, + ): Promise { + if (!this.feedbackEndpoint) { + throw new Error('Feedback endpoint is not configured'); + } + + try { + // always use backstage token for Jarvis + // TODO: maybe this should be configurable + const { token } = await this.identityApi.getCredentials(); + + // Submit feedback without Authorization header to avoid CORS preflight + const { status } = await axios.post( + this.feedbackEndpoint, + { + type: feedback.type, + reason: feedback.reason, + additionalFeedback: feedback.additionalFeedback || '', + timestamp: createTimestamp(), + message: message.text, + }, + { + headers: { + Authorization: `Bearer ${token}`, // Commented out to avoid CORS preflight + 'Content-Type': 'application/json', + }, + }, + ); + if (status !== 200) { + throw new Error('Failed to submit feedback'); + } + } catch (error) { + const err = error as AxiosError; + if (err?.isAxiosError) { + throw new Error( + `Error submitting feedback: ${[err.message, err.cause?.message] + .filter(Boolean) + .join(' - ')}`, + ); + } + throw new Error(err.message); + } + } + public async cancelTask(taskId: string): Promise { try { if (!this.client) { diff --git a/workspaces/agent-forge/plugins/agent-forge/src/components/AgentForgePage.tsx b/workspaces/agent-forge/plugins/agent-forge/src/components/AgentForgePage.tsx index bc520e630c2..4d7613901b8 100644 --- a/workspaces/agent-forge/plugins/agent-forge/src/components/AgentForgePage.tsx +++ b/workspaces/agent-forge/plugins/agent-forge/src/components/AgentForgePage.tsx @@ -57,7 +57,7 @@ import { DEFAULT_SUGGESTIONS, DEFAULT_THINKING_MESSAGES, } from '../constants'; -import { Message } from '../types'; +import { Message, Feedback } from '../types'; import { ChatSession, ChatStorage } from '../types/chat'; import { createTimestamp } from '../utils'; import { ChatContainer } from './ChatContainer'; @@ -293,10 +293,13 @@ export function AgentForgePage() { const backendUrl = config.getOptionalString('agentForge.baseUrl') || config.getString('backend.baseUrl'); - const authApiId = - config.getOptionalString('agentForge.authApiId'); // Optional - only needed if using a custom auth provider + const authApiId = config.getOptionalString('agentForge.authApiId'); // Optional - only needed if using a custom auth provider const useOpenIDToken = config.getOptionalBoolean('agentForge.useOpenIDToken') ?? false; + const enableFeedback = + config.getOptionalBoolean('agentForge.enableFeedback') ?? false; + const feedbackEndpoint = + config.getOptionalString('agentForge.feedbackEndpoint') ?? null; const requestTimeout = config.getOptionalNumber('agentForge.requestTimeout') || 300; const enableStreaming = @@ -340,10 +343,15 @@ export function AgentForgePage() { // OpenIdConnectApiRef - only create if authApiId is provided const OpenIdConnectApiRef: ApiRef< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi - > | null = authApiId ? createApiRef({ - id: authApiId, - }) : null; - const openIdConnectApi = OpenIdConnectApiRef ? useApi(OpenIdConnectApiRef) : null; + > | null = authApiId + ? createApiRef({ + id: authApiId, + }) + : null; + // eslint-disable-next-line react-hooks/rules-of-hooks + const openIdConnectApi = OpenIdConnectApiRef + ? useApi(OpenIdConnectApiRef) + : null; // Create initial session factory const createInitialSession = useCallback( @@ -405,6 +413,7 @@ export function AgentForgePage() { const [suggestions, setSuggestions] = useState(initialSuggestions); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); + const [feedback, setFeedback] = useState<{ [key: number]: Feedback }>({}); const [connectionStatus, setConnectionStatus] = useState< 'checking' | 'connected' | 'disconnected' >('checking'); @@ -922,11 +931,14 @@ export function AgentForgePage() { authApiId, ); + console.log('🔧 Feedback endpoint:', feedbackEndpoint); + console.log('🔧 Feedback enabled:', enableFeedback); + try { const api = new ChatbotApi( backendUrl, { identityApi, openIdConnectApi }, - { requestTimeout, useOpenIDToken }, + { requestTimeout, useOpenIDToken, feedbackEndpoint }, ); // Wrap API methods to catch any remaining A2A client exceptions @@ -3582,6 +3594,73 @@ export function AgentForgePage() { [currentSessionId, handleMessageSubmit, addMessageToSession], ); + // Feedback handlers + const handleFeedbackChange = useCallback( + (index: number, newFeedback: Feedback) => { + if (!enableFeedback) return; + setFeedback(prev => ({ + ...prev, + [index]: newFeedback, + })); + }, + [enableFeedback], + ); + + const handleFeedbackSubmit = useCallback( + async (index: number, feedbackData: Feedback) => { + if (!enableFeedback) return; + + const session = sessions.find(s => s.contextId === currentSessionId); + if (!session) return; + + const message = session.messages[index]; + if (!message) return; + + try { + await chatbotApi?.submitFeedback(message, feedbackData); + alertApi.post({ + severity: 'success', + message: 'Thank you for your feedback!', + }); + + setFeedback(prev => ({ + ...prev, + [index]: { + ...feedbackData, + submitted: true, + showFeedbackOptions: false, + }, + })); + + // Update message in session + setSessions(prev => + prev.map(s => { + if (s.contextId === currentSessionId) { + return { + ...s, + messages: s.messages.map((msg, i) => { + if (i === index) { + return { ...msg, showFeedbackOptions: false }; + } + return msg; + }), + updatedAt: new Date(), + }; + } + return s; + }), + ); + } catch (error) { + alertApi.post({ + severity: 'error', + message: + 'There was an error submitting your feedback. Please try again.', + }); + } + }, + [sessions, currentSessionId, chatbotApi, alertApi, enableFeedback], + ); + const resetChat = () => { console.log('🔄 Reset chat triggered'); if (currentSessionId) { @@ -3802,6 +3881,10 @@ export function AgentForgePage() { currentOperation={currentOperation} isInOperationalMode={isInOperationalMode} onMetadataSubmit={handleMetadataSubmit} + enableFeedback={enableFeedback} + feedback={feedback} + onFeedbackChange={handleFeedbackChange} + onFeedbackSubmit={handleFeedbackSubmit} fontSizes={{ messageText: fontSizes.messageText, codeBlock: fontSizes.codeBlock, diff --git a/workspaces/agent-forge/plugins/agent-forge/src/components/ChatContainer.tsx b/workspaces/agent-forge/plugins/agent-forge/src/components/ChatContainer.tsx index 36a61e84ca6..ffff0e910ed 100644 --- a/workspaces/agent-forge/plugins/agent-forge/src/components/ChatContainer.tsx +++ b/workspaces/agent-forge/plugins/agent-forge/src/components/ChatContainer.tsx @@ -36,7 +36,7 @@ import React, { useState, memo, } from 'react'; -import { Message } from '../types'; +import { Message, Feedback } from '../types'; import { ChatMessage } from './ChatMessage'; const useStyles = makeStyles(theme => ({ @@ -221,6 +221,12 @@ export interface ChatContainerProps { onSuggestionClick: (suggestion: string) => void; onMetadataSubmit?: (messageId: string, data: Record) => void; + // Feedback props + enableFeedback?: boolean; + feedback?: { [key: number]: Feedback }; + onFeedbackChange?: (index: number, feedback: Feedback) => void; + onFeedbackSubmit?: (index: number, feedback: Feedback) => void; + // Scroll-based message loading onScroll?: ( scrollTop: number, @@ -258,6 +264,10 @@ const MessagesList = memo(function MessagesList({ autoExpandExecutionPlans, executionPlanLoading, onMetadataSubmit, + enableFeedback, + feedback, + onFeedbackChange, + onFeedbackSubmit, }: { messages: Message[]; botName: string; @@ -273,6 +283,10 @@ const MessagesList = memo(function MessagesList({ autoExpandExecutionPlans?: Set; executionPlanLoading?: Set; onMetadataSubmit?: (messageId: string, data: Record) => void; + enableFeedback?: boolean; + feedback?: { [key: number]: Feedback }; + onFeedbackChange?: (index: number, feedback: Feedback) => void; + onFeedbackSubmit?: (index: number, feedback: Feedback) => void; }) { // Memoize font sizes to prevent re-creating object on every render const memoizedFontSizes = useMemo( @@ -307,6 +321,18 @@ const MessagesList = memo(function MessagesList({ autoExpandExecutionPlans={autoExpandExecutionPlans} executionPlanLoading={executionPlanLoading} onMetadataSubmit={onMetadataSubmit} + enableFeedback={enableFeedback} + messageFeedback={feedback?.[index]} + onFeedbackChange={ + onFeedbackChange + ? newFeedback => onFeedbackChange(index, newFeedback) + : undefined + } + onFeedbackSubmit={ + onFeedbackSubmit + ? feedbackData => onFeedbackSubmit(index, feedbackData) + : undefined + } /> ))} @@ -342,6 +368,10 @@ export const ChatContainer = memo(function ChatContainer({ botName, botIcon, inputPlaceholder, + enableFeedback = false, + feedback, + onFeedbackChange, + onFeedbackSubmit, fontSizes, onMessageSubmit, onCancelRequest, @@ -598,9 +628,14 @@ export const ChatContainer = memo(function ChatContainer({ botIcon={botIcon} fontSizes={fontSizes} executionPlanBuffer={executionPlanBuffer} + executionPlanHistory={executionPlanHistory} autoExpandExecutionPlans={autoExpandExecutionPlans} executionPlanLoading={executionPlanLoading} onMetadataSubmit={onMetadataSubmit} + enableFeedback={enableFeedback} + feedback={feedback} + onFeedbackChange={onFeedbackChange} + onFeedbackSubmit={onFeedbackSubmit} /> {isTyping && ( diff --git a/workspaces/agent-forge/plugins/agent-forge/src/components/ChatFeedback.tsx b/workspaces/agent-forge/plugins/agent-forge/src/components/ChatFeedback.tsx new file mode 100644 index 00000000000..21f474be459 --- /dev/null +++ b/workspaces/agent-forge/plugins/agent-forge/src/components/ChatFeedback.tsx @@ -0,0 +1,266 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { Message, Feedback as FeedbackType } from '../types'; +import { ChatMessage } from './ChatMessage'; +import { Box, Chip, Button, TextField } from '@material-ui/core'; +import { alertApiRef, useApi } from '@backstage/core-plugin-api'; +import { ChatbotApi } from '../apis'; +import { FeedbackButton, Feedback as FeedbackEnum } from './FeedbackButton'; + +interface ChatFeedbackProps { + messages: Message[]; + feedback: { [key: number]: FeedbackType }; + setFeedback: React.Dispatch< + React.SetStateAction<{ [key: number]: FeedbackType }> + >; + setMessages: React.Dispatch>; + chatbotApi: ChatbotApi; + botName?: string; + botIcon?: string; + fontSizes?: { + messageText?: string; + codeBlock?: string; + inlineCode?: string; + timestamp?: string; + }; + executionPlanBuffer?: Record; + executionPlanHistory?: Record; + autoExpandExecutionPlans?: Set; + executionPlanLoading?: Set; + onMetadataSubmit?: (messageId: string, data: Record) => void; +} + +function ChatFeedback({ + messages, + feedback, + setFeedback, + setMessages, + chatbotApi, + botName, + botIcon, + fontSizes, + executionPlanBuffer, + executionPlanHistory, + autoExpandExecutionPlans, + executionPlanLoading, + onMetadataSubmit, +}: ChatFeedbackProps) { + const alertApi = useApi(alertApiRef); + + function handleFeedback(index: number, type: FeedbackEnum) { + const feedbackType = type === FeedbackEnum.LIKE ? 'like' : 'dislike'; + setFeedback(prevFeedback => { + const newFeedback = { ...prevFeedback }; + if (!newFeedback[index]) { + newFeedback[index] = {}; + } + + if (newFeedback[index].type === feedbackType) { + newFeedback[index].type = undefined; + newFeedback[index].showFeedbackOptions = false; + } else { + newFeedback[index].type = feedbackType; + newFeedback[index].showFeedbackOptions = true; + } + + return newFeedback; + }); + } + + function handleFeedbackReason(index: number, reason: string) { + setFeedback(prevFeedback => { + const newFeedback = { ...prevFeedback }; + if (!newFeedback[index]) { + newFeedback[index] = {}; + } + newFeedback[index].reason = reason; + newFeedback[index].promptForFeedback = reason === 'Other'; + return newFeedback; + }); + } + + async function handleSubmitFeedback(index: number) { + const feedbackData = feedback[index]; + if (!feedbackData) { + return; + } + + try { + await chatbotApi.submitFeedback(messages[index], feedbackData); + alertApi.post({ + severity: 'success', + message: 'Thank you for your feedback!', + }); + setFeedback(prevFeedback => { + const newFeedback = { ...prevFeedback }; + newFeedback[index].submitted = true; + newFeedback[index].showFeedbackOptions = false; + return newFeedback; + }); + const updatedMessages = messages.map((msg, i) => { + if (i === index) { + return { ...msg, showFeedbackOptions: false }; + } + return msg; + }); + setMessages(updatedMessages); + } catch (error) { + alertApi.post({ + severity: 'error', + message: + 'There was an error submitting your feedback. Please try again.', + }); + } + } + + function handleCopyToClipboard(index: number) { + const messageText = messages[index]?.text || ''; + window.navigator.clipboard + .writeText(messageText) + .then(() => { + alertApi.post({ + severity: 'success', + message: 'Text copied to clipboard', + }); + }) + .catch(() => { + alertApi.post({ + severity: 'error', + message: 'Failed to copy text', + }); + }); + } + + return ( + <> + {messages.map((message, index) => { + const isLiked = feedback[index]?.type === 'like'; + const showFeedbackOptions = feedback[index]?.showFeedbackOptions; + const feedbackSubmitted = feedback[index]?.submitted; + + return ( +
+ + + {!message.isUser && ( + + handleFeedback(index, fb)} + handleCopyToClipBoard={() => handleCopyToClipboard(index)} + /> + + )} + + {showFeedbackOptions && !message.isUser && ( + + + {isLiked + ? [ + 'Very Helpful', + 'Accurate', + 'Simplified My Task', + 'Other', + ].map(reason => ( + handleFeedbackReason(index, reason)} + style={{ margin: 4 }} + /> + )) + : [ + 'Inaccurate', + 'Poorly Formatted', + 'Incomplete', + 'Off-topic', + 'Other', + ].map(reason => ( + handleFeedbackReason(index, reason)} + style={{ margin: 4 }} + /> + ))} + + {feedback[index]?.promptForFeedback && ( + + setFeedback(prevFeedback => { + const newFeedback = { ...prevFeedback }; + if (!newFeedback[index]) { + newFeedback[index] = {}; + } + newFeedback[index].additionalFeedback = e.target.value; + return newFeedback; + }) + } + style={{ marginBottom: 8 }} + /> + )} + + + )} +
+ ); + })} + + ); +} + +export default ChatFeedback; diff --git a/workspaces/agent-forge/plugins/agent-forge/src/components/ChatMessage.tsx b/workspaces/agent-forge/plugins/agent-forge/src/components/ChatMessage.tsx index 42644120774..dd6e3aca404 100644 --- a/workspaces/agent-forge/plugins/agent-forge/src/components/ChatMessage.tsx +++ b/workspaces/agent-forge/plugins/agent-forge/src/components/ChatMessage.tsx @@ -32,6 +32,7 @@ import { DialogActions, Button, Snackbar, + Chip, } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import FileCopyIcon from '@material-ui/icons/FileCopy'; @@ -57,6 +58,8 @@ import React, { useMemo, } from 'react'; import { MetadataInputForm } from './MetadataInputForm'; +import { FeedbackButton, Feedback as FeedbackEnum } from './FeedbackButton'; +import { Feedback as FeedbackType } from '../types'; const useStyles = makeStyles(theme => ({ messageBox: { @@ -260,6 +263,11 @@ export interface ChatMessageProps { autoExpandExecutionPlans?: Set; executionPlanLoading?: Set; onMetadataSubmit?: (messageId: string, data: Record) => void; + // Feedback props + enableFeedback?: boolean; + messageFeedback?: FeedbackType; + onFeedbackChange?: (feedback: FeedbackType) => void; + onFeedbackSubmit?: (feedback: FeedbackType) => void; } /** @@ -276,6 +284,10 @@ export const ChatMessage = memo(function ChatMessage({ executionPlanHistory, autoExpandExecutionPlans, onMetadataSubmit, + enableFeedback = false, + messageFeedback, + onFeedbackChange, + onFeedbackSubmit, }: ChatMessageProps) { const classes = useStyles(); const identityApi = useApi(identityApiRef); @@ -540,6 +552,57 @@ export const ChatMessage = memo(function ChatMessage({ } }; + // Feedback handlers + const handleFeedback = useCallback( + (type: FeedbackEnum) => { + if (!onFeedbackChange) return; + + const feedbackType = type === FeedbackEnum.LIKE ? 'like' : 'dislike'; + const newFeedback = { ...messageFeedback }; + + if (newFeedback.type === feedbackType) { + newFeedback.type = undefined; + newFeedback.showFeedbackOptions = false; + } else { + newFeedback.type = feedbackType; + newFeedback.showFeedbackOptions = true; + } + + onFeedbackChange(newFeedback); + }, + [messageFeedback, onFeedbackChange], + ); + + const handleFeedbackReason = useCallback( + (reason: string) => { + if (!onFeedbackChange) return; + + const newFeedback = { ...messageFeedback }; + newFeedback.reason = reason; + newFeedback.promptForFeedback = reason === 'Other'; + onFeedbackChange(newFeedback); + }, + [messageFeedback, onFeedbackChange], + ); + + const handleSubmitFeedback = useCallback(() => { + if (!onFeedbackSubmit || !messageFeedback) return; + onFeedbackSubmit(messageFeedback); + }, [messageFeedback, onFeedbackSubmit]); + + const handleCopyMessage = useCallback(async () => { + try { + const textToCopy = message.text?.replace(/⟦|⟧/g, '') || ''; + await copyTextToClipboard(textToCopy); + showToast('Message copied to clipboard'); + } catch (error) { + alertApi.post({ + message: 'Failed to copy message', + severity: 'error', + }); + } + }, [message.text, copyTextToClipboard, showToast, alertApi]); + // Custom code component with syntax highlighting const CodeBlock = ({ inline, className, children, ...props }: any) => { const match = /language-(\w+)/.exec(className || ''); @@ -1245,7 +1308,6 @@ export const ChatMessage = memo(function ChatMessage({ (

)} + {/* Feedback Buttons */} + {enableFeedback && onFeedbackChange && ( + + + + )} + + {/* Feedback Options */} + {enableFeedback && + messageFeedback?.showFeedbackOptions && + onFeedbackChange && ( + + + {messageFeedback.type === 'like' + ? [ + 'Very Helpful', + 'Accurate', + 'Simplified My Task', + 'Other', + ].map(reason => ( + handleFeedbackReason(reason)} + style={{ margin: 4 }} + /> + )) + : [ + 'Inaccurate', + 'Poorly Formatted', + 'Incomplete', + 'Off-topic', + 'Other', + ].map(reason => ( + handleFeedbackReason(reason)} + style={{ margin: 4 }} + /> + ))} + + {messageFeedback.promptForFeedback && ( + +