SarahXia0405 commited on
Commit
4c4a8cf
·
verified ·
1 Parent(s): 1a5dd5e

Update web/src/components/ChatArea.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/ChatArea.tsx +239 -210
web/src/components/ChatArea.tsx CHANGED
@@ -1,257 +1,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
  }
 
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
  }