Skip to content

Commit 6e74b09

Browse files
authored
feat: add feedback module to databunny (#111)
1 parent b17a75c commit 6e74b09

File tree

4 files changed

+263
-53
lines changed

4 files changed

+263
-53
lines changed

apps/dashboard/app/(main)/websites/[id]/assistant/components/chat-section.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ export default function ChatSection() {
9292
sendMessage,
9393
scrollToBottom,
9494
resetChat,
95+
handleVote,
96+
handleFeedbackComment,
9597
} = useChat();
9698

9799
const hasMessages = messages.length > 1;
@@ -190,8 +192,14 @@ export default function ChatSection() {
190192

191193
{hasMessages && (
192194
<div className="space-y-4">
193-
{messages.map((message) => (
194-
<MessageBubble key={message.id} message={message} />
195+
{messages.map((message, index) => (
196+
<MessageBubble
197+
handleFeedbackComment={handleFeedbackComment}
198+
handleVote={handleVote}
199+
isLastMessage={index === messages.length - 1}
200+
key={message.id}
201+
message={message}
202+
/>
195203
))}
196204
</div>
197205
)}

apps/dashboard/app/(main)/websites/[id]/assistant/components/message-bubble.tsx

Lines changed: 205 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,15 @@ import {
22
ChartBarIcon,
33
ChartLineIcon,
44
ChartPieIcon,
5+
CopyIcon,
56
HashIcon,
6-
} from '@phosphor-icons/react/ssr';
7+
PaperPlaneRightIcon,
8+
ThumbsDownIcon,
9+
ThumbsUpIcon,
10+
XIcon,
11+
} from '@phosphor-icons/react';
12+
import { useState } from 'react';
13+
import { Action, Actions } from '@/components/ai-elements/actions';
714
import { Loader } from '@/components/ai-elements/loader';
815
import {
916
Message as AIMessage,
@@ -16,10 +23,16 @@ import {
1623
ReasoningTrigger,
1724
} from '@/components/ai-elements/reasoning';
1825
import { Response } from '@/components/ai-elements/response';
26+
import { Button } from '@/components/ui/button';
27+
import { Textarea } from '@/components/ui/textarea';
28+
import type { useChat, Vote } from '../hooks/use-chat';
1929
import type { Message } from '../types/message';
2030

2131
interface MessageBubbleProps {
2232
message: Message;
33+
handleVote: ReturnType<typeof useChat>['handleVote'];
34+
handleFeedbackComment: ReturnType<typeof useChat>['handleFeedbackComment'];
35+
isLastMessage: boolean;
2336
}
2437

2538
const getChartIcon = (chartType: string) => {
@@ -88,75 +101,222 @@ function InProgressMessage({ message }: { message: Message }) {
88101
);
89102
}
90103

91-
function CompletedMessage({
92-
message,
93-
isUser,
104+
function FeedbackInput({
105+
onSubmit,
106+
onCancel,
94107
}: {
108+
onSubmit: (feedbackText: string) => void;
109+
onCancel: () => void;
110+
}) {
111+
const [feedbackText, setFeedbackText] = useState('');
112+
113+
const handleSubmit = () => {
114+
if (!feedbackText.trim()) {
115+
return;
116+
}
117+
118+
onSubmit(feedbackText.trim());
119+
onCancel();
120+
};
121+
122+
const handleCancel = () => {
123+
setFeedbackText('');
124+
onCancel();
125+
};
126+
127+
return (
128+
<div className="slide-in-from-top-2 fade-in-0 mt-3 animate-in space-y-3 rounded-md border border-border/50 bg-muted/30 p-3 duration-200">
129+
<div className="text-muted-foreground text-xs">
130+
Help us improve by sharing what went wrong:
131+
</div>
132+
<Textarea
133+
className="resize-none"
134+
onChange={(e) => setFeedbackText(e.target.value)}
135+
onKeyDown={(e) => {
136+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
137+
e.preventDefault();
138+
handleSubmit();
139+
}
140+
}}
141+
placeholder="Please describe what went wrong..."
142+
rows={4}
143+
value={feedbackText}
144+
/>
145+
<div className="flex justify-end gap-2">
146+
<Button onClick={handleCancel} size="sm" variant="ghost">
147+
<XIcon />
148+
Cancel
149+
</Button>
150+
<Button
151+
disabled={!feedbackText.trim()}
152+
onClick={handleSubmit}
153+
size="sm"
154+
>
155+
<PaperPlaneRightIcon />
156+
Submit Feedback
157+
</Button>
158+
</div>
159+
</div>
160+
);
161+
}
162+
163+
interface CompletedMessageProps {
95164
message: Message;
96165
isUser: boolean;
97-
}) {
98-
const hasThinkingSteps =
99-
message.thinkingSteps && message.thinkingSteps.length > 0;
166+
handleVote: ReturnType<typeof useChat>['handleVote'];
167+
handleFeedbackComment: ReturnType<typeof useChat>['handleFeedbackComment'];
168+
isLastMessage: boolean;
169+
}
170+
171+
function CompletedMessage({
172+
message,
173+
isUser,
174+
handleVote,
175+
handleFeedbackComment,
176+
isLastMessage,
177+
}: CompletedMessageProps) {
178+
const [voteType, setVoteType] = useState<Vote | null>(null);
179+
const [showFeedbackInput, setShowFeedbackInput] = useState(false);
180+
const hasThinkingSteps = Boolean(message.thinkingSteps?.length);
181+
182+
const resetFeedback = () => {
183+
setShowFeedbackInput(false);
184+
};
185+
186+
const handleSubmitFeedback = (feedbackText: string) => {
187+
handleFeedbackComment(message.id, feedbackText);
188+
setShowFeedbackInput(false);
189+
};
190+
191+
const handleFeedbackButtonClick = (type: Vote) => {
192+
if (type === 'downvote') {
193+
setShowFeedbackInput(true);
194+
}
195+
196+
setVoteType(type);
197+
handleVote(message.id, type);
198+
};
199+
200+
const showUpVoteButton = voteType !== 'downvote';
201+
const showDownVoteButton = voteType !== 'upvote';
202+
const isVoteButtonClicked = voteType !== null;
100203

101204
return (
102205
<AIMessage from={isUser ? 'user' : 'assistant'}>
103-
{!isUser && (
104-
<MessageAvatar
105-
name={isUser ? 'You' : 'Databunny'}
106-
src={'/databunny.webp'}
107-
/>
108-
)}
109-
<MessageContent>
110-
<Response>{message.content}</Response>
206+
<div className="space-y-2">
207+
<MessageContent>
208+
<Response>{message.content}</Response>
111209

112-
{hasThinkingSteps && !isUser && message.content && (
113-
<div className="mt-2">
114-
<ThinkingStepsReasoning steps={message.thinkingSteps || []} />
115-
</div>
116-
)}
210+
{hasThinkingSteps && !isUser && message.content && (
211+
<div className="mt-2">
212+
<ThinkingStepsReasoning steps={message.thinkingSteps || []} />
213+
</div>
214+
)}
117215

118-
{message.responseType === 'metric' &&
119-
message.metricValue !== undefined &&
120-
!isUser && (
121-
<div className="mt-4 rounded border border-primary/20 bg-primary/5 p-4">
122-
<div className="flex min-w-0 items-center gap-3">
123-
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded bg-primary/10">
124-
<HashIcon className="h-4 w-4 text-primary" />
125-
</div>
126-
<div className="min-w-0 flex-1">
127-
<div className="truncate font-medium text-muted-foreground text-xs uppercase tracking-wide">
128-
{message.metricLabel || 'Result'}
216+
{message.responseType === 'metric' &&
217+
message.metricValue !== undefined &&
218+
!isUser && (
219+
<div className="mt-4 rounded border border-primary/20 bg-primary/5 p-4">
220+
<div className="flex min-w-0 items-center gap-3">
221+
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded bg-primary/10">
222+
<HashIcon className="h-4 w-4 text-primary" />
129223
</div>
130-
<div className="mt-2 break-words font-bold text-foreground text-lg">
131-
{typeof message.metricValue === 'number'
132-
? message.metricValue.toLocaleString()
133-
: message.metricValue}
224+
<div className="min-w-0 flex-1">
225+
<div className="truncate font-medium text-muted-foreground text-xs uppercase tracking-wide">
226+
{message.metricLabel || 'Result'}
227+
</div>
228+
<div className="mt-2 break-words font-bold text-foreground text-lg">
229+
{typeof message.metricValue === 'number'
230+
? message.metricValue.toLocaleString()
231+
: message.metricValue}
232+
</div>
134233
</div>
135234
</div>
136235
</div>
236+
)}
237+
238+
{message.hasVisualization && !isUser && (
239+
<div className="mt-3 border-border/30 border-t pt-3">
240+
<div className="flex items-center gap-2 text-muted-foreground text-xs">
241+
{getChartIcon(message.chartType || 'bar')}
242+
<span>Visualization generated in the data panel.</span>
243+
</div>
137244
</div>
138245
)}
246+
</MessageContent>
247+
{!isUser && isLastMessage && (
248+
<>
249+
<Actions>
250+
<Action
251+
className="cursor-pointer transition-colors hover:bg-blue-50 hover:text-blue-500 active:bg-blue-100"
252+
onClick={() => navigator.clipboard.writeText(message.content)}
253+
tooltip="Copy message"
254+
>
255+
<CopyIcon className="h-4 w-4" />
256+
</Action>
257+
{showUpVoteButton && (
258+
<Action
259+
className={`cursor-pointer transition-colors ${
260+
isVoteButtonClicked
261+
? 'bg-green-100 text-green-600'
262+
: 'hover:bg-green-50 hover:text-green-500 active:bg-green-100'
263+
}`}
264+
disabled={isVoteButtonClicked}
265+
onClick={() => handleFeedbackButtonClick('upvote')}
266+
tooltip="Upvote"
267+
>
268+
<ThumbsUpIcon className="h-4 w-4" />
269+
</Action>
270+
)}
271+
{showDownVoteButton && (
272+
<Action
273+
className={`cursor-pointer transition-colors ${
274+
isVoteButtonClicked
275+
? 'bg-red-100 text-red-600'
276+
: 'hover:bg-red-50 hover:text-red-500 active:bg-red-100'
277+
}`}
278+
disabled={isVoteButtonClicked}
279+
onClick={() => handleFeedbackButtonClick('downvote')}
280+
tooltip="Downvote"
281+
>
282+
<ThumbsDownIcon className="h-4 w-4" />
283+
</Action>
284+
)}
285+
</Actions>
139286

140-
{message.hasVisualization && !isUser && (
141-
<div className="mt-3 border-border/30 border-t pt-3">
142-
<div className="flex items-center gap-2 text-muted-foreground text-xs">
143-
{getChartIcon(message.chartType || 'bar')}
144-
<span>Visualization generated in the data panel.</span>
145-
</div>
146-
</div>
287+
{showFeedbackInput && (
288+
<FeedbackInput
289+
onCancel={resetFeedback}
290+
onSubmit={handleSubmitFeedback}
291+
/>
292+
)}
293+
</>
147294
)}
148-
</MessageContent>
295+
</div>
149296
</AIMessage>
150297
);
151298
}
152299

153-
export function MessageBubble({ message }: MessageBubbleProps) {
300+
export function MessageBubble({
301+
message,
302+
handleVote,
303+
handleFeedbackComment,
304+
isLastMessage,
305+
}: MessageBubbleProps) {
154306
const isUser = message.type === 'user';
155307
const isInProgress = message.type === 'assistant' && !message.content;
156308

157309
if (isInProgress) {
158310
return <InProgressMessage message={message} />;
159311
}
160312

161-
return <CompletedMessage isUser={isUser} message={message} />;
313+
return (
314+
<CompletedMessage
315+
handleFeedbackComment={handleFeedbackComment}
316+
handleVote={handleVote}
317+
isLastMessage={isLastMessage}
318+
isUser={isUser}
319+
message={message}
320+
/>
321+
);
162322
}

apps/dashboard/app/(main)/websites/[id]/assistant/hooks/use-chat.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { StreamingUpdate } from '@databuddy/shared';
22
import { useAtom } from 'jotai';
33
import { useCallback, useEffect, useState } from 'react';
4+
import { toast } from 'sonner';
5+
import { trpc } from '@/lib/trpc';
46
import {
57
inputValueAtom,
68
isLoadingAtom,
@@ -25,6 +27,8 @@ function generateWelcomeMessage(websiteName?: string): string {
2527
return `Hello! I'm Databunny, your data analyst for ${websiteName || 'your website'}. I can help you understand your data with charts, single metrics, or detailed answers. Try asking me questions like:\n\n${examples.map((prompt: string) => `• "${prompt}"`).join('\n')}\n\nI'll automatically choose the best way to present your data - whether it's a chart, a single number, or a detailed explanation.`;
2628
}
2729

30+
export type Vote = 'upvote' | 'downvote';
31+
2832
export function useChat() {
2933
const [model] = useAtom(modelAtom);
3034
const [websiteId] = useAtom(websiteIdAtom);
@@ -40,6 +44,14 @@ export function useChat() {
4044
throw new Error('Website ID is required');
4145
}
4246

47+
const addFeedback = trpc.assistant.addFeedback.useMutation({
48+
onError: (error) => {
49+
toast.error(
50+
error.message || 'Failed to submit feedback. Please try again.'
51+
);
52+
},
53+
});
54+
4355
// Initialize with welcome message if no messages exist
4456
useEffect(() => {
4557
if (messages.length === 0 && websiteData?.name) {
@@ -304,6 +316,30 @@ export function useChat() {
304316
setConversationId(undefined);
305317
}, [websiteData?.name, setMessages, setInputValue, setIsLoading]);
306318

319+
const handleVote = useCallback(
320+
(messageId: string, type: Vote) => {
321+
addFeedback.mutate({ messageId, type });
322+
},
323+
[addFeedback]
324+
);
325+
326+
const handleFeedbackComment = useCallback(
327+
(messageId: string, comment: string) => {
328+
addFeedback.mutate(
329+
{ messageId, comment },
330+
{
331+
onSuccess: () => {
332+
toast.success('Feedback submitted');
333+
},
334+
onError: () => {
335+
toast.error('Failed to submit feedback');
336+
},
337+
}
338+
);
339+
},
340+
[addFeedback]
341+
);
342+
307343
return {
308344
messages,
309345
inputValue,
@@ -314,5 +350,7 @@ export function useChat() {
314350
handleKeyPress,
315351
resetChat,
316352
scrollToBottom,
353+
handleVote,
354+
handleFeedbackComment,
317355
};
318356
}

0 commit comments

Comments
 (0)