SarahXia0405 commited on
Commit
a0e379d
·
verified ·
1 Parent(s): f9c57f6

Update web/src/components/ChatArea.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/ChatArea.tsx +210 -239
web/src/components/ChatArea.tsx CHANGED
@@ -1,286 +1,257 @@
1
- import React, { useEffect, useMemo, useRef, useState } from 'react';
2
- import type { FileType, Message, UploadedFile, LearningMode, SpaceType } from '../App';
3
  import { Button } from './ui/button';
 
 
 
 
 
 
 
 
4
 
5
- type Props = {
6
- messages: Message[];
7
  onSendMessage: (content: string) => void;
8
-
9
  uploadedFiles: UploadedFile[];
10
  onFileUpload: (files: File[]) => void;
11
  onRemoveFile: (index: number) => void;
12
  onFileTypeChange: (index: number, type: FileType) => void;
13
-
14
- // ✅ 新增:真正触发上传
15
- onUploadSingle: (index: number) => void;
16
- onUploadAllPending: () => void;
17
-
18
  memoryProgress: number;
19
  isLoggedIn: boolean;
20
-
21
  learningMode: LearningMode;
22
  onClearConversation: () => void;
23
- onLearningModeChange: (m: LearningMode) => void;
24
-
25
  spaceType: SpaceType;
26
- };
27
-
28
- const FILE_TYPE_OPTIONS: Array<{ value: FileType; label: string }> = [
29
- { value: 'syllabus', label: 'Syllabus' },
30
- { value: 'lecture-slides', label: 'Lecture Slides' },
31
- { value: 'literature-review', label: 'Literature Review / Paper' },
32
- { value: 'other', label: 'Other' },
33
- ];
34
-
35
- export function ChatArea(props: Props) {
36
- const {
37
- messages,
38
- onSendMessage,
39
- uploadedFiles,
40
- onFileUpload,
41
- onRemoveFile,
42
- onFileTypeChange,
43
- onUploadSingle,
44
- onUploadAllPending,
45
- memoryProgress,
46
- learningMode,
47
- onClearConversation,
48
- } = props;
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  const [input, setInput] = useState('');
51
- const [showFileTypeModal, setShowFileTypeModal] = useState(false);
 
 
 
52
 
53
- const fileInputRef = useRef<HTMLInputElement | null>(null);
 
 
54
 
55
- const pendingFileIndexes = useMemo(() => {
56
- return uploadedFiles
57
- .map((f, i) => ({ f, i }))
58
- .filter(x => !x.f.uploaded)
59
- .map(x => x.i);
60
- }, [uploadedFiles]);
61
 
62
- // 有 pending 文件就自动打开选择类型弹窗
63
  useEffect(() => {
64
- if (pendingFileIndexes.length > 0) {
65
- setShowFileTypeModal(true);
66
- }
67
- }, [pendingFileIndexes.length]);
 
 
 
 
 
 
 
68
 
69
- const handlePickFiles = () => {
70
- fileInputRef.current?.click();
 
 
 
 
 
 
71
  };
72
 
73
- const handleFilesChosen = (e: React.ChangeEvent<HTMLInputElement>) => {
74
- const files = Array.from(e.target.files || []);
75
- if (files.length) onFileUpload(files);
76
- e.target.value = ''; // 允许再次选同一个文件
 
77
  };
78
 
79
- const handleSend = () => {
80
- const trimmed = input.trim();
81
- if (!trimmed) return;
82
- onSendMessage(trimmed);
83
- setInput('');
 
84
  };
85
 
86
- return (
87
- <div className="flex-1 flex flex-col min-w-0">
88
- {/* Top bar inside chat area */}
89
- <div className="flex items-center justify-between px-4 py-3 border-b border-border bg-background">
90
- <div className="flex items-center gap-3">
91
- <div className="text-sm text-muted-foreground">
92
- Mode: <span className="font-medium text-foreground">{learningMode}</span>
93
- </div>
94
- <div className="text-sm text-muted-foreground">
95
- Memory: <span className="font-medium text-foreground">{memoryProgress}%</span>
96
- </div>
97
- </div>
98
- <div className="flex items-center gap-2">
99
- <Button variant="outline" onClick={onClearConversation}>
100
- Clear
101
- </Button>
102
- <Button variant="outline" onClick={() => setShowFileTypeModal(true)} disabled={uploadedFiles.length === 0}>
103
- Files
104
- </Button>
105
- </div>
106
- </div>
107
 
108
- {/* Messages */}
109
- <div className="flex-1 overflow-auto px-4 py-4 space-y-4">
110
- {messages.map(m => (
111
- <div key={m.id} className={`max-w-3xl ${m.role === 'user' ? 'ml-auto' : 'mr-auto'}`}>
112
- <div
113
- className={`rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm border ${
114
- m.role === 'user'
115
- ? 'bg-primary text-primary-foreground border-primary/30'
116
- : 'bg-card text-card-foreground border-border'
117
- }`}
118
- >
119
- {m.content}
120
- </div>
121
 
122
- {m.references && m.references.length > 0 && (
123
- <div className="mt-2 text-xs text-muted-foreground">
124
- <div className="font-medium mb-1">References</div>
125
- <ul className="list-disc pl-5 space-y-1">
126
- {m.references.map((r, idx) => (
127
- <li key={`${m.id}-ref-${idx}`}>{r}</li>
128
- ))}
129
- </ul>
130
- </div>
131
- )}
132
- </div>
133
- ))}
134
- </div>
135
 
136
- {/* Composer + Upload area */}
137
- <div className="border-t border-border bg-background p-4 space-y-3">
138
- {/* Course Materials box */}
139
- <div className="rounded-xl border border-dashed border-border p-4 bg-card">
140
- <div className="flex items-center justify-between">
141
- <div>
142
- <div className="text-sm font-medium">Course Materials</div>
143
- <div className="text-xs text-muted-foreground">Upload .pdf / .docx / .pptx</div>
144
- </div>
145
- <div className="flex items-center gap-2">
146
- <Button variant="outline" onClick={handlePickFiles}>
147
- Add files
148
- </Button>
149
- <input
150
- ref={fileInputRef}
151
- type="file"
152
- className="hidden"
153
- multiple
154
- accept=".pdf,.docx,.pptx"
155
- onChange={handleFilesChosen}
156
- />
157
- </div>
 
 
 
158
  </div>
 
159
 
160
- {uploadedFiles.length > 0 && (
161
- <div className="mt-3 space-y-2">
162
- {uploadedFiles.map((f, idx) => (
163
- <div key={`${f.file.name}-${idx}`} className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
164
- <div className="min-w-0">
165
- <div className="text-sm truncate">{f.file.name}</div>
166
- <div className="text-xs text-muted-foreground">
167
- {Math.round(f.file.size / 1024 / 1024 * 10) / 10} MB · Type: {f.type}{' '}
168
- {f.uploaded ? `· Uploaded (+${f.uploadedChunks ?? 0} chunks)` : '· Pending'}
169
- </div>
170
- </div>
171
 
172
- <div className="flex items-center gap-2">
173
- <Button variant="outline" size="sm" onClick={() => onRemoveFile(idx)}>
174
- Remove
175
- </Button>
 
 
 
 
 
 
176
  </div>
177
  </div>
178
- ))}
179
- </div>
180
- )}
181
- </div>
182
 
183
- {/* Input */}
184
- <div className="flex items-end gap-2">
185
- <div className="flex-1">
186
- <textarea
187
- value={input}
188
- onChange={e => setInput(e.target.value)}
189
- placeholder="Ask Clare anything about the course..."
190
- className="w-full min-h-[44px] max-h-[180px] rounded-xl border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 resize-none"
191
- onKeyDown={e => {
192
- if (e.key === 'Enter' && !e.shiftKey) {
193
- e.preventDefault();
194
- handleSend();
195
- }
196
- }}
197
- />
198
  </div>
199
- <Button onClick={handleSend} className="rounded-xl">
200
- Send
201
- </Button>
202
  </div>
203
- </div>
204
-
205
- {/* File Type Modal */}
206
- {showFileTypeModal && (
207
- <div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/40 p-4">
208
- <div className="w-full max-w-2xl rounded-2xl bg-card border border-border shadow-xl">
209
- <div className="flex items-center justify-between px-5 py-4 border-b border-border">
210
- <div>
211
- <div className="text-base font-semibold">Select File Types</div>
212
- <div className="text-sm text-muted-foreground">
213
- Select a type for each file, then click Upload. This ensures `doc_type` is correct.
214
- </div>
215
- </div>
216
- <Button variant="outline" onClick={() => setShowFileTypeModal(false)}>
217
- Close
218
- </Button>
219
- </div>
220
-
221
- <div className="p-5 space-y-3 max-h-[60vh] overflow-auto">
222
- {uploadedFiles.length === 0 && (
223
- <div className="text-sm text-muted-foreground">No files yet.</div>
224
- )}
225
 
226
- {uploadedFiles.map((f, idx) => (
227
- <div key={`modal-${f.file.name}-${idx}`} className="rounded-xl border border-border p-4">
228
- <div className="flex items-start justify-between gap-3">
229
- <div className="min-w-0">
230
- <div className="text-sm font-medium truncate">{f.file.name}</div>
231
- <div className="text-xs text-muted-foreground mt-1">
232
- {Math.round(f.file.size / 1024 / 1024 * 10) / 10} MB · {f.uploaded ? 'Uploaded' : 'Pending'}
233
- {f.uploaded ? ` (+${f.uploadedChunks ?? 0} chunks)` : ''}
234
- </div>
235
- </div>
236
-
237
- <div className="flex items-center gap-2">
238
- {!f.uploaded && (
239
- <Button onClick={() => onUploadSingle(idx)} className="rounded-xl">
240
- Upload
241
- </Button>
242
- )}
243
- <Button variant="outline" onClick={() => onRemoveFile(idx)} className="rounded-xl">
244
- Remove
245
- </Button>
246
- </div>
247
- </div>
248
 
249
- <div className="mt-3 flex items-center gap-3">
250
- <div className="text-sm text-muted-foreground w-24">File Type</div>
251
- <select
252
- value={f.type}
253
- onChange={e => onFileTypeChange(idx, e.target.value as FileType)}
254
- className="flex-1 rounded-xl border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30"
255
- disabled={!!f.uploaded}
 
 
 
 
 
256
  >
257
- {FILE_TYPE_OPTIONS.map(opt => (
258
- <option key={opt.value} value={opt.value}>
259
- {opt.label}
260
- </option>
261
- ))}
262
- </select>
263
- </div>
264
- </div>
265
- ))}
266
- </div>
 
 
 
 
 
 
 
 
267
 
268
- <div className="px-5 py-4 border-t border-border flex items-center justify-between">
269
- <div className="text-sm text-muted-foreground">
270
- Pending files: <span className="font-medium text-foreground">{pendingFileIndexes.length}</span>
271
- </div>
272
- <div className="flex items-center gap-2">
273
- <Button variant="outline" onClick={() => setShowFileTypeModal(false)}>
274
- Cancel
275
- </Button>
276
- <Button onClick={onUploadAllPending} disabled={pendingFileIndexes.length === 0}>
277
- Upload All Pending
 
 
 
 
 
 
278
  </Button>
279
  </div>
280
- </div>
281
  </div>
282
  </div>
283
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  </div>
285
  );
286
  }
 
1
+ // web/src/components/ChatArea.tsx
2
+ import React, { useState, useRef, useEffect } from 'react';
3
  import { Button } from './ui/button';
4
+ import { Textarea } from './ui/textarea';
5
+ import { Send, ArrowDown, Trash2, Share2 } from 'lucide-react';
6
+ import { Message } from './Message';
7
+ import { FileUploadArea } from './FileUploadArea';
8
+ import { MemoryLine } from './MemoryLine';
9
+ import type { Message as MessageType, LearningMode, UploadedFile, FileType, SpaceType } from '../App';
10
+ import { toast } from 'sonner';
11
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu';
12
 
13
+ interface ChatAreaProps {
14
+ messages: MessageType[];
15
  onSendMessage: (content: string) => void;
 
16
  uploadedFiles: UploadedFile[];
17
  onFileUpload: (files: File[]) => void;
18
  onRemoveFile: (index: number) => void;
19
  onFileTypeChange: (index: number, type: FileType) => void;
 
 
 
 
 
20
  memoryProgress: number;
21
  isLoggedIn: boolean;
 
22
  learningMode: LearningMode;
23
  onClearConversation: () => void;
24
+ onLearningModeChange: (mode: LearningMode) => void;
 
25
  spaceType: SpaceType;
26
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
+ export function ChatArea({
29
+ messages,
30
+ onSendMessage,
31
+ uploadedFiles,
32
+ onFileUpload,
33
+ onRemoveFile,
34
+ onFileTypeChange,
35
+ memoryProgress,
36
+ isLoggedIn,
37
+ learningMode,
38
+ onClearConversation,
39
+ onLearningModeChange,
40
+ spaceType,
41
+ }: ChatAreaProps) {
42
  const [input, setInput] = useState('');
43
+ const [isTyping, setIsTyping] = useState(false);
44
+ const [showScrollButton, setShowScrollButton] = useState(false);
45
+ const messagesEndRef = useRef<HTMLDivElement>(null);
46
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
47
 
48
+ const scrollToBottom = () => {
49
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
50
+ };
51
 
52
+ useEffect(() => {
53
+ scrollToBottom();
54
+ }, [messages]);
 
 
 
55
 
 
56
  useEffect(() => {
57
+ const handleScroll = () => {
58
+ if (scrollContainerRef.current) {
59
+ const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
60
+ setShowScrollButton(scrollHeight - scrollTop - clientHeight > 100);
61
+ }
62
+ };
63
+
64
+ const container = scrollContainerRef.current;
65
+ container?.addEventListener('scroll', handleScroll);
66
+ return () => container?.removeEventListener('scroll', handleScroll);
67
+ }, []);
68
 
69
+ const handleSubmit = (e: React.FormEvent) => {
70
+ e.preventDefault();
71
+ if (!input.trim() || !isLoggedIn) return;
72
+
73
+ onSendMessage(input);
74
+ setInput('');
75
+ setIsTyping(true);
76
+ setTimeout(() => setIsTyping(false), 1200);
77
  };
78
 
79
+ const handleKeyDown = (e: React.KeyboardEvent) => {
80
+ if (e.key === 'Enter' && !e.shiftKey) {
81
+ e.preventDefault();
82
+ handleSubmit(e);
83
+ }
84
  };
85
 
86
+ const modeLabels: Record<LearningMode, string> = {
87
+ concept: 'Concept Explainer',
88
+ socratic: 'Socratic Tutor',
89
+ exam: 'Exam Prep',
90
+ assignment: 'Assignment Helper',
91
+ summary: 'Quick Summary',
92
  };
93
 
94
+ const handleClearClick = () => {
95
+ if (messages.length <= 1) {
96
+ toast.info('No conversation to clear');
97
+ return;
98
+ }
99
+ if (window.confirm('Are you sure you want to clear the conversation? This cannot be undone.')) {
100
+ onClearConversation();
101
+ toast.success('Conversation cleared');
102
+ }
103
+ };
 
 
 
 
 
 
 
 
 
 
 
104
 
105
+ const handleShareClick = () => {
106
+ if (messages.length <= 1) {
107
+ toast.info('No conversation to share');
108
+ return;
109
+ }
110
+ const conversationText = messages
111
+ .map((msg) => `${msg.role === 'user' ? 'You' : 'Clare'}: ${msg.content}`)
112
+ .join('\n\n');
 
 
 
 
 
113
 
114
+ navigator.clipboard
115
+ .writeText(conversationText)
116
+ .then(() => toast.success('Conversation copied to clipboard!'))
117
+ .catch(() => toast.error('Failed to copy conversation'));
118
+ };
 
 
 
 
 
 
 
 
119
 
120
+ return (
121
+ <div className="flex flex-col h-full">
122
+ <div className="flex-1 relative border-b-2 border-border">
123
+ {messages.length > 1 && (
124
+ <div className="absolute top-4 right-12 z-10 flex gap-2">
125
+ <Button
126
+ variant="ghost"
127
+ size="sm"
128
+ onClick={handleShareClick}
129
+ disabled={!isLoggedIn}
130
+ className="gap-2 bg-background/95 backdrop-blur-sm shadow-sm hover:shadow-md transition-all group"
131
+ >
132
+ <Share2 className="h-4 w-4" />
133
+ <span className="hidden group-hover:inline">Share</span>
134
+ </Button>
135
+ <Button
136
+ variant="ghost"
137
+ size="sm"
138
+ onClick={handleClearClick}
139
+ disabled={!isLoggedIn}
140
+ className="gap-2 bg-background/95 backdrop-blur-sm shadow-sm hover:shadow-md transition-all group"
141
+ >
142
+ <Trash2 className="h-4 w-4" />
143
+ <span className="hidden group-hover:inline">Clear</span>
144
+ </Button>
145
  </div>
146
+ )}
147
 
148
+ <div ref={scrollContainerRef} className="h-full max-h-[600px] overflow-y-auto px-4 py-6 pb-36">
149
+ <div className="max-w-4xl mx-auto space-y-6">
150
+ {messages.map((message) => (
151
+ <Message key={message.id} message={message} showSenderInfo={spaceType === 'group'} />
152
+ ))}
 
 
 
 
 
 
153
 
154
+ {isTyping && (
155
+ <div className="flex gap-3">
156
+ <div className="w-8 h-8 rounded-full bg-gradient-to-br from-purple-500 to-blue-500 flex items-center justify-center flex-shrink-0">
157
+ <span className="text-white text-sm">C</span>
158
+ </div>
159
+ <div className="bg-muted rounded-2xl px-4 py-3">
160
+ <div className="flex gap-1">
161
+ <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '0ms' }} />
162
+ <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '150ms' }} />
163
+ <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: '300ms' }} />
164
  </div>
165
  </div>
166
+ </div>
167
+ )}
 
 
168
 
169
+ <div ref={messagesEndRef} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  </div>
 
 
 
171
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
+ {showScrollButton && (
174
+ <div className="absolute bottom-24 left-1/2 -translate-x-1/2 z-20">
175
+ <Button
176
+ variant="secondary"
177
+ size="icon"
178
+ className="rounded-full shadow-lg hover:shadow-xl transition-shadow bg-background"
179
+ onClick={scrollToBottom}
180
+ >
181
+ <ArrowDown className="h-4 w-4" />
182
+ </Button>
183
+ </div>
184
+ )}
 
 
 
 
 
 
 
 
 
 
185
 
186
+ <div className="absolute bottom-0 left-0 right-0 bg-background/95 backdrop-blur-sm z-10">
187
+ <div className="max-w-4xl mx-auto px-4 py-4">
188
+ <form onSubmit={handleSubmit}>
189
+ <div className="relative">
190
+ <DropdownMenu>
191
+ <DropdownMenuTrigger asChild>
192
+ <Button
193
+ variant="ghost"
194
+ size="sm"
195
+ className="absolute bottom-3 left-2 gap-1.5 h-8 px-2 text-xs z-10 hover:bg-muted/50"
196
+ disabled={!isLoggedIn}
197
+ type="button"
198
  >
199
+ <span>{modeLabels[learningMode]}</span>
200
+ <svg className="h-3 w-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
201
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
202
+ </svg>
203
+ </Button>
204
+ </DropdownMenuTrigger>
205
+ <DropdownMenuContent align="start" className="w-56">
206
+ {(['concept', 'socratic', 'exam', 'assignment', 'summary'] as LearningMode[]).map((m) => (
207
+ <DropdownMenuItem
208
+ key={m}
209
+ onClick={() => onLearningModeChange(m)}
210
+ className={learningMode === m ? 'bg-accent' : ''}
211
+ >
212
+ <span className="font-medium">{modeLabels[m]}</span>
213
+ </DropdownMenuItem>
214
+ ))}
215
+ </DropdownMenuContent>
216
+ </DropdownMenu>
217
 
218
+ <Textarea
219
+ value={input}
220
+ onChange={(e) => setInput(e.target.value)}
221
+ onKeyDown={handleKeyDown}
222
+ placeholder={
223
+ isLoggedIn
224
+ ? spaceType === 'group'
225
+ ? 'Type a message... (mention @Clare to get AI assistance)'
226
+ : 'Ask Clare anything about the course...'
227
+ : 'Please log in on the right to start chatting...'
228
+ }
229
+ disabled={!isLoggedIn}
230
+ className="min-h-[80px] pl-4 pr-12 resize-none bg-background border-2 border-border"
231
+ />
232
+ <Button type="submit" size="icon" disabled={!input.trim() || !isLoggedIn} className="absolute bottom-2 right-2 rounded-full">
233
+ <Send className="h-4 w-4" />
234
  </Button>
235
  </div>
236
+ </form>
237
  </div>
238
  </div>
239
+ </div>
240
+
241
+ <div className="bg-card">
242
+ <div className="max-w-4xl mx-auto px-4 py-4">
243
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
244
+ <FileUploadArea
245
+ uploadedFiles={uploadedFiles}
246
+ onFileUpload={onFileUpload}
247
+ onRemoveFile={onRemoveFile}
248
+ onFileTypeChange={onFileTypeChange}
249
+ disabled={!isLoggedIn}
250
+ />
251
+ <MemoryLine progress={memoryProgress} />
252
+ </div>
253
+ </div>
254
+ </div>
255
  </div>
256
  );
257
  }