SarahXia0405 commited on
Commit
34baa5d
·
verified ·
1 Parent(s): cd29280

Update web/src/components/Message.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/Message.tsx +352 -327
web/src/components/Message.tsx CHANGED
@@ -1,372 +1,397 @@
1
- import React, { useEffect, useMemo, useRef, useState } from "react";
2
- import { Button } from "./ui/button";
3
- import {
4
- Upload,
5
- File as FileIcon,
6
- X,
7
- FileText,
8
- Presentation,
9
- Image as ImageIcon,
10
- } from "lucide-react";
11
- import { Card } from "./ui/card";
12
- import { Badge } from "./ui/badge";
13
- import {
14
- Select,
15
- SelectContent,
16
- SelectItem,
17
- SelectTrigger,
18
- SelectValue,
19
- } from "./ui/select";
20
- import {
21
- Dialog,
22
- DialogContent,
23
- DialogDescription,
24
- DialogFooter,
25
- DialogHeader,
26
- DialogTitle,
27
- } from "./ui/dialog";
28
- import type { UploadedFile, FileType } from "../App";
29
-
30
- interface FileUploadAreaProps {
31
- uploadedFiles: UploadedFile[];
32
- onFileUpload: (files: File[]) => void;
33
- onRemoveFile: (index: number) => void;
34
- onFileTypeChange: (index: number, type: FileType) => void;
35
- disabled?: boolean;
36
  }
37
 
38
- interface PendingFile {
39
- file: File;
40
- type: FileType;
41
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
- const ACCEPT_EXTS = [".pdf", ".docx", ".pptx", ".png", ".jpg", ".jpeg", ".webp", ".gif"];
44
- const ACCEPT_ATTR = ".pdf,.docx,.pptx,.png,.jpg,.jpeg,.webp,.gif";
45
 
46
- function isImageFile(file: File) {
47
- if (file.type?.startsWith("image/")) return true;
48
- const n = file.name.toLowerCase();
49
- return [".png", ".jpg", ".jpeg", ".webp", ".gif"].some((ext) => n.endsWith(ext));
50
- }
51
 
52
- function getFileIcon(filename: string) {
53
- const lower = filename.toLowerCase();
54
- if (lower.endsWith(".pdf")) return FileText;
55
- if (lower.endsWith(".pptx")) return Presentation;
56
- if (lower.endsWith(".docx")) return FileIcon;
57
- if ([".png", ".jpg", ".jpeg", ".webp", ".gif"].some((ext) => lower.endsWith(ext)))
58
- return ImageIcon;
59
- return FileIcon;
60
- }
61
 
62
- function formatFileSize(bytes: number) {
63
- if (bytes < 1024) return bytes + " B";
64
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
65
- return (bytes / (1024 * 1024)).toFixed(1) + " MB";
66
- }
67
 
68
- export function FileUploadArea({
69
- uploadedFiles,
70
- onFileUpload,
71
- onRemoveFile,
72
- onFileTypeChange,
73
- disabled = false,
74
- }: FileUploadAreaProps) {
75
- const [isDragging, setIsDragging] = useState(false);
76
- const fileInputRef = useRef<HTMLInputElement>(null);
77
- const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
78
- const [showTypeDialog, setShowTypeDialog] = useState(false);
79
-
80
- // ===== objectURL cache(更稳:不用 state,避免时序问题)=====
81
- const urlCacheRef = useRef<Map<string, string>>(new Map());
82
-
83
- const fingerprint = (f: File) => `${f.name}::${f.size}::${f.lastModified}`;
84
-
85
- const allFiles = useMemo(() => {
86
- return [
87
- ...uploadedFiles.map((u) => u.file),
88
- ...pendingFiles.map((p) => p.file),
89
- ];
90
- }, [uploadedFiles, pendingFiles]);
91
-
92
- // 维护 cache:只保留当前需要的 image url;移除的立即 revoke
93
- useEffect(() => {
94
- const need = new Set<string>();
95
- for (const f of allFiles) {
96
- if (!isImageFile(f)) continue;
97
- need.add(fingerprint(f));
98
- }
99
 
100
- // revoke removed
101
- for (const [key, url] of urlCacheRef.current.entries()) {
102
- if (!need.has(key)) {
103
- try {
104
- URL.revokeObjectURL(url);
105
- } catch {
106
- // ignore
107
- }
108
- urlCacheRef.current.delete(key);
109
- }
110
- }
111
 
112
- // create missing
113
- for (const f of allFiles) {
114
- if (!isImageFile(f)) continue;
115
- const key = fingerprint(f);
116
- if (!urlCacheRef.current.has(key)) {
117
- urlCacheRef.current.set(key, URL.createObjectURL(f));
118
- }
119
- }
120
- }, [allFiles]);
121
-
122
- // unmount:全部 revoke
123
- useEffect(() => {
124
- return () => {
125
- for (const url of urlCacheRef.current.values()) {
126
- try {
127
- URL.revokeObjectURL(url);
128
- } catch {
129
- // ignore
130
- }
131
- }
132
- urlCacheRef.current.clear();
133
- };
134
- }, []);
135
 
136
- const getPreviewUrl = (file: File) => {
137
- const key = fingerprint(file);
138
- return urlCacheRef.current.get(key);
139
- };
140
 
141
- const filterSupportedFiles = (files: File[]) => {
142
- return files.filter((file) => {
143
- if (isImageFile(file)) return true;
144
- const lower = file.name.toLowerCase();
145
- return [".pdf", ".docx", ".pptx"].some((ext) => lower.endsWith(ext));
146
- });
147
- };
148
 
149
- const handleDragOver = (e: React.DragEvent) => {
150
- e.preventDefault();
151
- if (!disabled) setIsDragging(true);
152
- };
153
 
154
- const handleDragLeave = () => setIsDragging(false);
155
 
156
- const handleDrop = (e: React.DragEvent) => {
157
- e.preventDefault();
158
- setIsDragging(false);
159
- if (disabled) return;
160
 
161
- const files = filterSupportedFiles(Array.from(e.dataTransfer.files));
162
- if (files.length > 0) {
163
- setPendingFiles(files.map((file) => ({ file, type: "other" as FileType })));
164
- setShowTypeDialog(true);
165
- }
166
  };
167
 
168
- const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
169
- const files = filterSupportedFiles(Array.from(e.target.files || []));
170
- if (files.length > 0) {
171
- setPendingFiles(files.map((file) => ({ file, type: "other" as FileType })));
172
- setShowTypeDialog(true);
 
 
 
 
 
 
 
 
 
173
  }
174
- e.target.value = "";
175
  };
176
 
177
- const handleConfirmUpload = () => {
178
- onFileUpload(pendingFiles.map((pf) => pf.file));
179
-
180
- const startIndex = uploadedFiles.length;
181
- pendingFiles.forEach((pf, idx) => {
182
- setTimeout(() => {
183
- onFileTypeChange(startIndex + idx, pf.type);
184
- }, 0);
185
- });
186
 
187
- setPendingFiles([]);
188
- setShowTypeDialog(false);
189
  };
190
 
191
- const handleCancelUpload = () => {
192
- setPendingFiles([]);
193
- setShowTypeDialog(false);
 
 
 
 
194
  };
195
 
196
- const handlePendingFileTypeChange = (index: number, type: FileType) => {
197
- setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)));
 
 
 
 
 
 
 
 
 
 
 
198
  };
199
 
200
- const renderLeading = (file: File) => {
201
- if (isImageFile(file)) {
202
- const src = getPreviewUrl(file);
203
- return (
204
- <div className="h-12 w-12 rounded-md overflow-hidden bg-background border border-border flex-shrink-0">
205
- {src ? (
 
 
 
 
 
 
206
  <img
207
- src={src}
208
- alt={file.name}
209
- className="h-full w-full object-cover"
210
- draggable={false}
 
 
211
  />
212
- ) : (
213
- <div className="h-full w-full flex items-center justify-center text-muted-foreground">
214
- <ImageIcon className="h-5 w-5" />
215
- </div>
216
  )}
217
  </div>
218
- );
219
- }
 
 
 
220
 
221
- const Icon = getFileIcon(file.name);
222
- return <Icon className="h-5 w-5 text-muted-foreground flex-shrink-0" />;
223
- };
224
 
225
- return (
226
- <Card className="p-4 space-y-3">
227
- <div className="flex items-center justify-between">
228
- <h4 className="text-sm">Course Materials</h4>
229
- {uploadedFiles.length > 0 && (
230
- <Badge variant="secondary">{uploadedFiles.length} file(s)</Badge>
 
 
 
 
 
 
 
 
231
  )}
232
- </div>
233
 
234
- {/* Upload Area */}
235
- <div
236
- onDragOver={handleDragOver}
237
- onDragLeave={handleDragLeave}
238
- onDrop={handleDrop}
239
- className={[
240
- "border-2 border-dashed rounded-lg p-4 text-center transition-colors",
241
- isDragging ? "border-primary bg-accent" : "border-border",
242
- disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
243
- ].join(" ")}
244
- onClick={() => !disabled && fileInputRef.current?.click()}
245
- >
246
- <Upload className="h-6 w-6 mx-auto mb-2 text-muted-foreground" />
247
- <p className="text-sm text-muted-foreground mb-1">
248
- {disabled ? "Please log in to upload" : "Drop files or click to upload"}
249
- </p>
250
- <p className="text-xs text-muted-foreground">{ACCEPT_EXTS.join(", ")}</p>
251
-
252
- <input
253
- ref={fileInputRef}
254
- type="file"
255
- multiple
256
- accept={ACCEPT_ATTR}
257
- onChange={handleFileSelect}
258
- className="hidden"
259
- disabled={disabled}
260
- />
261
- </div>
262
 
263
- {/* Uploaded Files List */}
264
- {uploadedFiles.length > 0 && (
265
- <div className="space-y-3 max-h-64 overflow-y-auto">
266
- {uploadedFiles.map((uploadedFile, index) => {
267
- const f = uploadedFile.file;
268
- return (
269
- <div key={index} className="p-3 bg-muted rounded-md space-y-2">
270
- <div className="flex items-center gap-3 group">
271
- {renderLeading(f)}
272
-
273
- <div className="flex-1 min-w-0">
274
- <p className="text-sm truncate">{f.name}</p>
275
- <p className="text-xs text-muted-foreground">{formatFileSize(f.size)}</p>
276
- </div>
277
-
278
- <Button
279
- variant="ghost"
280
- size="icon"
281
- className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
282
- onClick={(e) => {
283
- e.stopPropagation();
284
- onRemoveFile(index);
285
- }}
286
- title="Remove"
287
- >
288
- <X className="h-3 w-3" />
289
- </Button>
290
- </div>
291
-
292
- <div className="space-y-1">
293
- <label className="text-xs text-muted-foreground">File Type</label>
294
- <Select
295
- value={uploadedFile.type}
296
- onValueChange={(value) => onFileTypeChange(index, value as FileType)}
297
- >
298
- <SelectTrigger className="h-8 text-xs">
299
- <SelectValue />
300
- </SelectTrigger>
301
- <SelectContent>
302
- <SelectItem value="syllabus">Syllabus</SelectItem>
303
- <SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem>
304
- <SelectItem value="literature-review">Literature Review / Paper</SelectItem>
305
- <SelectItem value="other">Other Course Document</SelectItem>
306
- </SelectContent>
307
- </Select>
308
- </div>
309
- </div>
310
- );
311
- })}
312
  </div>
313
- )}
314
 
315
- {/* Type Selection Dialog */}
316
- {showTypeDialog && (
317
- <Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}>
318
- <DialogContent className="sm:max-w-[425px]">
319
- <DialogHeader>
320
- <DialogTitle>Select File Types</DialogTitle>
321
- <DialogDescription>
322
- Please select the type for each file you are uploading.
323
- </DialogDescription>
324
- </DialogHeader>
325
-
326
- <div className="space-y-3 max-h-64 overflow-y-auto">
327
- {pendingFiles.map((pendingFile, index) => {
328
- const f = pendingFile.file;
329
- return (
330
- <div key={index} className="p-3 bg-muted rounded-md space-y-2">
331
- <div className="flex items-center gap-3">
332
- {renderLeading(f)}
333
- <div className="flex-1 min-w-0">
334
- <p className="text-sm truncate">{f.name}</p>
335
- <p className="text-xs text-muted-foreground">{formatFileSize(f.size)}</p>
336
- </div>
337
- </div>
338
-
339
- <div className="space-y-1">
340
- <label className="text-xs text-muted-foreground">File Type</label>
341
- <Select
342
- value={pendingFile.type}
343
- onValueChange={(value) => handlePendingFileTypeChange(index, value as FileType)}
344
- >
345
- <SelectTrigger className="h-8 text-xs">
346
- <SelectValue />
347
- </SelectTrigger>
348
- <SelectContent>
349
- <SelectItem value="syllabus">Syllabus</SelectItem>
350
- <SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem>
351
- <SelectItem value="literature-review">Literature Review / Paper</SelectItem>
352
- <SelectItem value="other">Other Course Document</SelectItem>
353
- </SelectContent>
354
- </Select>
355
- </div>
356
- </div>
357
- );
358
- })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  </div>
360
 
361
- <DialogFooter>
362
- <Button variant="outline" onClick={handleCancelUpload}>
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  Cancel
364
  </Button>
365
- <Button onClick={handleConfirmUpload}>Upload</Button>
366
- </DialogFooter>
367
- </DialogContent>
368
- </Dialog>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  )}
370
- </Card>
371
  );
372
  }
 
1
+ import React, { useState } from 'react';
2
+ import { Button } from './ui/button';
3
+ import {
4
+ Copy,
5
+ ThumbsUp,
6
+ ThumbsDown,
7
+ ChevronDown,
8
+ ChevronUp,
9
+ Check,
10
+ Bot,
11
+ X
12
+ } from 'lucide-react';
13
+ import { Badge } from './ui/badge';
14
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
15
+ import { Textarea } from './ui/textarea';
16
+ import type { Message as MessageType } from '../App';
17
+ import { toast } from 'sonner';
18
+ import clareAvatar from '../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png';
19
+
20
+ interface MessageProps {
21
+ key?: React.Key;
22
+ message: MessageType;
23
+ showSenderInfo?: boolean; // For group chat mode
24
+ isFirstGreeting?: boolean; // Indicates if this is the first greeting message
25
+ showNextButton?: boolean; // For quiz mode
26
+ onNextQuestion?: () => void; // For quiz mode
27
+ chatMode?: 'ask' | 'review' | 'quiz'; // Current chat mode
 
 
 
 
 
 
 
 
28
  }
29
 
30
+ // 反馈标签选项
31
+ const FEEDBACK_TAGS = {
32
+ 'not-helpful': [
33
+ 'Code was incorrect',
34
+ "Shouldn't have used Memory",
35
+ "Don't like the personality",
36
+ "Don't like the style",
37
+ 'Not factually correct',
38
+ ],
39
+ 'helpful': [
40
+ 'Accurate and helpful',
41
+ 'Clear explanation',
42
+ 'Good examples',
43
+ 'Solved my problem',
44
+ 'Well structured',
45
+ ],
46
+ };
47
+
48
+ export function Message({
49
+ message,
50
+ showSenderInfo = false,
51
+ isFirstGreeting = false,
52
+ showNextButton = false,
53
+ onNextQuestion,
54
+ chatMode = 'ask',
55
+ }: MessageProps) {
56
+ const [feedback, setFeedback] = useState<'helpful' | 'not-helpful' | null>(null);
57
+ const [copied, setCopied] = useState(false);
58
+ const [referencesOpen, setReferencesOpen] = useState(false);
59
+ const [showFeedbackArea, setShowFeedbackArea] = useState(false);
60
+ const [feedbackType, setFeedbackType] = useState<'helpful' | 'not-helpful' | null>(null);
61
+ const [feedbackText, setFeedbackText] = useState('');
62
+ const [selectedTags, setSelectedTags] = useState<string[]>([]);
63
+ const [isHovered, setIsHovered] = useState(false);
64
+ const [nextButtonClicked, setNextButtonClicked] = useState(false);
65
+
66
+ const isUser = message.role === 'user';
67
+ // For user messages: always show. For assistant messages: always show except first greeting (or review-1, quiz-1)
68
+ const isWelcomeMessage = isFirstGreeting || message.id === 'review-1' || message.id === 'quiz-1';
69
+ const shouldShowActions = isUser ? true : !isWelcomeMessage;
70
+
71
+ const handleCopy = async () => {
72
+ await navigator.clipboard.writeText(message.content);
73
+ setCopied(true);
74
+ toast.success('Message copied to clipboard');
75
+ setTimeout(() => setCopied(false), 2000);
76
+
77
+
78
+
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+
101
+
102
+
103
 
 
 
104
 
 
 
 
 
 
105
 
 
 
 
 
 
 
 
 
 
106
 
 
 
 
 
 
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
 
 
 
 
 
 
 
 
 
 
 
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
 
 
 
 
111
 
 
 
 
 
 
 
 
112
 
 
 
 
 
113
 
 
114
 
 
 
 
 
115
 
 
 
 
 
 
116
  };
117
 
118
+ const handleFeedbackClick = (type: 'helpful' | 'not-helpful') => {
119
+ if (feedback === type) {
120
+ // 如果点击的是已选中的反馈,则关闭反馈区域
121
+ setFeedback(null);
122
+ setShowFeedbackArea(false);
123
+ setFeedbackType(null);
124
+ setFeedbackText('');
125
+ setSelectedTags([]);
126
+ } else {
127
+ // 立即设置反馈状态(按钮会立即显示颜色)
128
+ setFeedback(type);
129
+ // 打开反馈区域
130
+ setFeedbackType(type);
131
+ setShowFeedbackArea(true);
132
  }
 
133
  };
134
 
135
+ const handleFeedbackClose = () => {
136
+ setShowFeedbackArea(false);
137
+ setFeedbackType(null);
138
+ setFeedbackText('');
139
+ setSelectedTags([]);
140
+ // 注意:不重置 feedback,保持点赞/点踩的状态
 
 
 
141
 
 
 
142
  };
143
 
144
+ const handleTagToggle = (tag: string) => {
145
+ setSelectedTags(prev =>
146
+ prev.includes(tag)
147
+ ? prev.filter(t => t !== tag)
148
+ : [...prev, tag]
149
+ );
150
+
151
  };
152
 
153
+ const handleFeedbackSubmit = () => {
154
+ // 这里可以发送反馈到后端
155
+ const feedbackData = {
156
+ type: feedbackType,
157
+ tags: selectedTags,
158
+ text: feedbackText,
159
+ messageId: message.id || message.content.substring(0, 50),
160
+ };
161
+
162
+ console.log('Feedback submitted:', feedbackData);
163
+ toast.success('感谢您的反馈!');
164
+ handleFeedbackClose();
165
+ // 保持反馈状态(点赞/点踩)
166
  };
167
 
168
+ return (
169
+ <div className={`flex gap-2 ${isUser && !showSenderInfo ? 'justify-end' : 'justify-start'} px-4`}>
170
+ {/* Avatar */}
171
+ {showSenderInfo && message.sender ? (
172
+ <div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
173
+ message.sender.isAI
174
+ ? 'overflow-hidden bg-white'
175
+ : 'overflow-hidden bg-white'
176
+ }`}>
177
+ {message.sender.isAI ? (
178
+ <img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
179
+ ) : (
180
  <img
181
+ src={
182
+ message.sender.avatar ||
183
+ `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(message.sender.email || message.sender.name)}`
184
+ }
185
+ alt={message.sender.name}
186
+ className="w-full h-full object-cover"
187
  />
 
 
 
 
188
  )}
189
  </div>
190
+ ) : !isUser ? (
191
+ <div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
192
+ <img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
193
+ </div>
194
+ ) : null}
195
 
 
 
 
196
 
197
+ <div
198
+ className={`group flex flex-col gap-2 ${isUser && !showSenderInfo ? 'items-end' : 'items-start'}`}
199
+ style={{ maxWidth: 'min(770px, calc(100% - 2rem))' }}
200
+ onMouseEnter={() => setIsHovered(true)}
201
+ onMouseLeave={() => setIsHovered(false)}
202
+ >
203
+ {/* Sender name in group chat */}
204
+ {showSenderInfo && message.sender && (
205
+ <div className="flex items-center gap-2 px-1">
206
+ <span className="text-xs">{message.sender.name}</span>
207
+ {message.sender.isAI && (
208
+ <Badge variant="secondary" className="text-xs h-4 px-1">AI</Badge>
209
+ )}
210
+ </div>
211
  )}
 
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
+ <div
215
+ className={`
216
+ rounded-2xl px-4 py-3
217
+ ${isUser && !showSenderInfo
218
+ ? 'bg-primary text-primary-foreground'
219
+ : 'bg-muted'
220
+ }
221
+ `}
222
+ >
223
+ <p className="whitespace-pre-wrap text-base">{message.content}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  </div>
 
225
 
226
+
227
+
228
+
229
+
230
+
231
+
232
+ {/* Next Question Button for Quiz Mode */}
233
+ {!isUser && showNextButton && !nextButtonClicked && chatMode === 'quiz' && onNextQuestion && (
234
+ <div className="mt-2">
235
+ <Button
236
+ onClick={() => {
237
+ setNextButtonClicked(true);
238
+ onNextQuestion();
239
+ }}
240
+ className="bg-primary hover:bg-primary/90"
241
+ >
242
+ Next Question
243
+ </Button>
244
+ </div>
245
+ )}
246
+
247
+ {/* References */}
248
+ {message.references && message.references.length > 0 && (
249
+ <Collapsible open={referencesOpen} onOpenChange={setReferencesOpen}>
250
+ <CollapsibleTrigger asChild>
251
+ <Button variant="ghost" size="sm" className="gap-1 h-7 text-xs">
252
+ {referencesOpen ? (
253
+ <ChevronUp className="h-3 w-3" />
254
+ ) : (
255
+ <ChevronDown className="h-3 w-3" />
256
+ )}
257
+ {message.references.length} {message.references.length === 1 ? 'reference' : 'references'}
258
+ </Button>
259
+ </CollapsibleTrigger>
260
+ <CollapsibleContent className="space-y-1 mt-1">
261
+ {message.references.map((ref, index) => (
262
+ <Badge key={index} variant="outline" className="text-xs">
263
+ {ref}
264
+ </Badge>
265
+ ))}
266
+ </CollapsibleContent>
267
+ </Collapsible>
268
+ )}
269
+
270
+ {/* Message Actions */}
271
+ {shouldShowActions && (
272
+ <div className="flex items-center gap-1">
273
+ <Button
274
+ variant="ghost"
275
+ size="icon"
276
+ className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
277
+ copied ? 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400' : ''
278
+ }`}
279
+ onClick={handleCopy}
280
+ title="Copy"
281
+ >
282
+ {copied ? (
283
+ <Check className="h-4 w-4" />
284
+ ) : (
285
+ <Copy className="h-4 w-4" />
286
+ )}
287
+ </Button>
288
+
289
+ {!isUser && (
290
+ <>
291
+ <Button
292
+ variant="ghost"
293
+ size="icon"
294
+ className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
295
+ feedback === 'helpful' ? 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400' : ''
296
+ }`}
297
+ onClick={() => handleFeedbackClick('helpful')}
298
+ title="Helpful"
299
+ >
300
+ <ThumbsUp className="h-4 w-4" />
301
+ </Button>
302
+ <Button
303
+ variant="ghost"
304
+ size="icon"
305
+ className={`h-7 w-7 rounded-md transition-all hover:bg-accent hover:scale-110 active:scale-95 ${
306
+ feedback === 'not-helpful' ? 'bg-red-100 text-red-600 dark:bg-red-900/20 dark:text-red-400' : ''
307
+ }`}
308
+ onClick={() => handleFeedbackClick('not-helpful')}
309
+ title="Not helpful"
310
+ >
311
+ <ThumbsDown className="h-4 w-4" />
312
+ </Button>
313
+ </>
314
+ )}
315
+ </div>
316
+ )}
317
+
318
+ {/* Feedback Area - 展开在消息下方 */}
319
+ {!isUser && showFeedbackArea && feedbackType && (
320
+ <div className="w-full mt-2 bg-gray-50 dark:bg-gray-800/50 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
321
+ <div className="flex items-start justify-between mb-4">
322
+ <h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
323
+ Tell us more:
324
+ </h4>
325
+ <Button
326
+ variant="ghost"
327
+ size="sm"
328
+ className="h-6 w-6 p-0"
329
+ onClick={handleFeedbackClose}
330
+ >
331
+ <X className="h-4 w-4" />
332
+ </Button>
333
+ </div>
334
+
335
+ {/* 标签按钮 */}
336
+ <div className="flex flex-wrap gap-2 mb-4">
337
+ {FEEDBACK_TAGS[feedbackType].map((tag) => (
338
+ <Button
339
+ key={tag}
340
+ variant={selectedTags.includes(tag) ? 'default' : 'outline'}
341
+ size="sm"
342
+ className="h-7 text-xs"
343
+ onClick={() => handleTagToggle(tag)}
344
+ >
345
+ {tag}
346
+ </Button>
347
+ ))}
348
  </div>
349
 
350
+ {/* 文本输入框 */}
351
+ <Textarea
352
+ className="min-h-[60px] mb-4 bg-gray-100/50 dark:bg-gray-700/50 border-gray-200 dark:border-gray-600"
353
+ value={feedbackText}
354
+ onChange={(e) => setFeedbackText(e.target.value)}
355
+ placeholder="Additional feedback (optional)..."
356
+ />
357
+
358
+ {/* 提交按钮 */}
359
+ <div className="flex justify-end gap-2">
360
+ <Button
361
+ variant="outline"
362
+ size="sm"
363
+ onClick={handleFeedbackClose}
364
+ >
365
  Cancel
366
  </Button>
367
+ <Button
368
+ size="sm"
369
+ onClick={handleFeedbackSubmit}
370
+ disabled={selectedTags.length === 0 && !feedbackText.trim()}
371
+ >
372
+ Submit
373
+ </Button>
374
+ </div>
375
+ </div>
376
+ )}
377
+ </div>
378
+
379
+ {isUser && !showSenderInfo && (
380
+ <div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
381
+ {message.sender ? (
382
+ <img
383
+ src={
384
+ message.sender.avatar ||
385
+ `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(message.sender.email || message.sender.name)}`
386
+ }
387
+ alt={message.sender.name}
388
+ className="w-full h-full object-cover"
389
+ />
390
+ ) : (
391
+ <span className="text-base">👤</span>
392
+ )}
393
+ </div>
394
  )}
395
+ </div>
396
  );
397
  }