Spaces:
Sleeping
Sleeping
| // web/src/App.tsx | |
| import React, { useState, useEffect, useMemo } from 'react'; | |
| import { Header } from './components/Header'; | |
| import { LeftSidebar } from './components/LeftSidebar'; | |
| import { ChatArea } from './components/ChatArea'; | |
| import { RightPanel } from './components/RightPanel'; | |
| import { FloatingActionButtons } from './components/FloatingActionButtons'; | |
| import { X, ChevronLeft, ChevronRight } from 'lucide-react'; | |
| import { Button } from './components/ui/button'; | |
| import { Toaster } from './components/ui/sonner'; | |
| import { toast } from 'sonner'; | |
| export interface Message { | |
| id: string; | |
| role: 'user' | 'assistant'; | |
| content: string; | |
| timestamp: Date; | |
| references?: string[]; | |
| sender?: GroupMember; | |
| } | |
| export interface User { | |
| name: string; | |
| email: string; // login 输入(原始输入) | |
| user_id: string; // 实际传后端的 user_id(这里等同 email/ID) | |
| } | |
| export interface GroupMember { | |
| id: string; | |
| name: string; | |
| email: string; | |
| avatar?: string; | |
| isAI?: boolean; | |
| } | |
| export type SpaceType = 'individual' | 'group'; | |
| export type FileType = 'syllabus' | 'lecture-slides' | 'literature-review' | 'other'; | |
| export interface UploadedFile { | |
| file: File; | |
| type: FileType; | |
| uploaded?: boolean; | |
| uploadedChunks?: number; | |
| } | |
| export type LearningMode = 'concept' | 'socratic' | 'exam' | 'assignment' | 'summary'; | |
| export type Language = 'auto' | 'en' | 'zh'; | |
| type ChatApiResp = { | |
| reply: string; | |
| session_status_md?: string; | |
| refs?: Array<{ source_file?: string; section?: string }>; | |
| latency_ms?: number; | |
| }; | |
| type UploadApiResp = { | |
| ok: boolean; | |
| added_chunks: number; | |
| status_md: string; | |
| }; | |
| type LoginApiResp = { | |
| ok: boolean; | |
| user: { name: string; user_id: string }; | |
| }; | |
| function mapFileTypeToDocType(t: FileType): string { | |
| switch (t) { | |
| case 'syllabus': | |
| return 'Syllabus'; | |
| case 'lecture-slides': | |
| return 'Lecture Slides'; | |
| case 'literature-review': | |
| return 'Literature Review / Paper'; | |
| default: | |
| return 'Other'; | |
| } | |
| } | |
| async function apiPostJson<T>(path: string, payload: any): Promise<T> { | |
| const res = await fetch(path, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload), | |
| }); | |
| if (!res.ok) { | |
| const txt = await res.text().catch(() => ''); | |
| throw new Error(`HTTP ${res.status}: ${txt || res.statusText}`); | |
| } | |
| return (await res.json()) as T; | |
| } | |
| async function apiPostForm<T>(path: string, form: FormData): Promise<T> { | |
| const res = await fetch(path, { method: 'POST', body: form }); | |
| if (!res.ok) { | |
| const txt = await res.text().catch(() => ''); | |
| throw new Error(`HTTP ${res.status}: ${txt || res.statusText}`); | |
| } | |
| return (await res.json()) as T; | |
| } | |
| function App() { | |
| const [isDarkMode, setIsDarkMode] = useState(() => { | |
| const saved = localStorage.getItem('theme'); | |
| return ( | |
| saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches) | |
| ); | |
| }); | |
| const [user, setUser] = useState<User | null>(null); | |
| const [messages, setMessages] = useState<Message[]>([ | |
| { | |
| id: '1', | |
| role: 'assistant', | |
| content: | |
| "Hi! I'm Clare, your AI teaching assistant for Module 10 – Responsible AI. Please log in on the right, upload materials (optional), and ask me anything.", | |
| timestamp: new Date(), | |
| }, | |
| ]); | |
| const [learningMode, setLearningMode] = useState<LearningMode>('concept'); | |
| const [language, setLanguage] = useState<Language>('auto'); | |
| const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]); | |
| const [memoryProgress, setMemoryProgress] = useState(40); | |
| const [leftSidebarOpen, setLeftSidebarOpen] = useState(false); | |
| const [rightPanelOpen, setRightPanelOpen] = useState(false); | |
| const [rightPanelVisible, setRightPanelVisible] = useState(true); | |
| const [spaceType, setSpaceType] = useState<SpaceType>('individual'); | |
| const [exportResult, setExportResult] = useState(''); | |
| const [resultType, setResultType] = useState<'export' | 'quiz' | 'summary' | null>(null); | |
| const [groupMembers] = useState<GroupMember[]>([ | |
| { id: 'clare', name: 'Clare AI', email: 'clare@ai.assistant', isAI: true }, | |
| { id: '1', name: 'Sarah Johnson', email: 'sarah.j@university.edu' }, | |
| { id: '2', name: 'Michael Chen', email: 'michael.c@university.edu' }, | |
| { id: '3', name: 'Emma Williams', email: 'emma.w@university.edu' }, | |
| ]); | |
| useEffect(() => { | |
| document.documentElement.classList.toggle('dark', isDarkMode); | |
| localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); | |
| }, [isDarkMode]); | |
| // ✅ 彻底去掉 hardcode:未登录 userId 为空 | |
| const userId = useMemo(() => (user?.user_id || '').trim(), [user]); | |
| // ✅ 未登录不可聊天/上传/feedback | |
| const isLoggedIn = useMemo(() => !!user && !!userId, [user, userId]); | |
| const currentDocTypeForChat = useMemo(() => { | |
| const hasSyllabus = uploadedFiles.some((f) => f.type === 'syllabus' && f.uploaded); | |
| if (hasSyllabus) return 'Syllabus'; | |
| const hasSlides = uploadedFiles.some((f) => f.type === 'lecture-slides' && f.uploaded); | |
| if (hasSlides) return 'Lecture Slides'; | |
| const hasLit = uploadedFiles.some((f) => f.type === 'literature-review' && f.uploaded); | |
| if (hasLit) return 'Literature Review / Paper'; | |
| return 'Other'; | |
| }, [uploadedFiles]); | |
| // ✅ 登录必须打后端 /api/login,确保 server session 有 name | |
| const handleLogin = async (name: string, emailOrId: string) => { | |
| const nameTrim = name.trim(); | |
| const idTrim = emailOrId.trim(); | |
| if (!nameTrim || !idTrim) { | |
| toast.error('Please fill in both name and Email/ID'); | |
| return; | |
| } | |
| try { | |
| const resp = await apiPostJson<LoginApiResp>('/api/login', { | |
| name: nameTrim, | |
| user_id: idTrim, | |
| }); | |
| if (!resp?.ok) throw new Error('Login failed (ok=false)'); | |
| // 后端回来的 user_id 为准(保持一致) | |
| setUser({ | |
| name: resp.user.name, | |
| email: idTrim, | |
| user_id: resp.user.user_id, | |
| }); | |
| toast.success('Logged in'); | |
| } catch (e: any) { | |
| console.error(e); | |
| toast.error(`Login failed: ${e?.message || 'unknown error'}`); | |
| } | |
| }; | |
| const handleLogout = () => { | |
| setUser(null); | |
| toast.message('Logged out'); | |
| }; | |
| const handleSendMessage = async (content: string) => { | |
| if (!content.trim()) return; | |
| if (!isLoggedIn) { | |
| toast.error('Please log in first'); | |
| return; | |
| } | |
| const shouldAIRespond = | |
| spaceType === 'individual' || content.toLowerCase().includes('@clare'); | |
| const sender: GroupMember | undefined = | |
| spaceType === 'group' && user | |
| ? { id: user.user_id, name: user.name, email: user.email } | |
| : undefined; | |
| const userMessage: Message = { | |
| id: Date.now().toString(), | |
| role: 'user', | |
| content, | |
| timestamp: new Date(), | |
| sender, | |
| }; | |
| setMessages((prev) => [...prev, userMessage]); | |
| if (!shouldAIRespond) return; | |
| try { | |
| const resp = await apiPostJson<ChatApiResp>('/api/chat', { | |
| user_id: userId, | |
| message: content, | |
| learning_mode: learningMode, | |
| language_preference: language === 'auto' ? 'Auto' : language, | |
| doc_type: currentDocTypeForChat, | |
| }); | |
| const refs = (resp.refs || []) | |
| .map((r) => [r.source_file, r.section].filter(Boolean).join(' / ')) | |
| .filter(Boolean); | |
| const assistantMessage: Message = { | |
| id: (Date.now() + 1).toString(), | |
| role: 'assistant', | |
| content: resp.reply || '(empty reply)', | |
| timestamp: new Date(), | |
| references: refs.length ? refs : undefined, | |
| sender: spaceType === 'group' ? groupMembers.find((m) => m.isAI) : undefined, | |
| }; | |
| setMessages((prev) => [...prev, assistantMessage]); | |
| } catch (e: any) { | |
| console.error(e); | |
| toast.error(`Chat failed: ${e?.message || 'unknown error'}`); | |
| setMessages((prev) => [ | |
| ...prev, | |
| { | |
| id: (Date.now() + 1).toString(), | |
| role: 'assistant', | |
| content: `Sorry — chat request failed. ${e?.message || ''}`, | |
| timestamp: new Date(), | |
| }, | |
| ]); | |
| } | |
| }; | |
| // ✅ 选文件:只入库,不上传 | |
| const handleFileUpload = (files: File[]) => { | |
| if (!isLoggedIn) { | |
| toast.error('Please log in first'); | |
| return; | |
| } | |
| const newFiles: UploadedFile[] = files.map((file) => ({ | |
| file, | |
| type: 'other', | |
| uploaded: false, | |
| })); | |
| setUploadedFiles((prev) => [...prev, ...newFiles]); | |
| toast.message('Files added. Select a File Type, then click Upload.'); | |
| }; | |
| const handleRemoveFile = (index: number) => { | |
| setUploadedFiles((prev) => prev.filter((_, i) => i !== index)); | |
| }; | |
| const handleFileTypeChange = (index: number, type: FileType) => { | |
| setUploadedFiles((prev) => prev.map((f, i) => (i === index ? { ...f, type } : f))); | |
| }; | |
| // ✅ 上传单个文件 | |
| const handleUploadSingle = async (index: number) => { | |
| if (!isLoggedIn) { | |
| toast.error('Please log in first'); | |
| return; | |
| } | |
| const target = uploadedFiles[index]; | |
| if (!target) return; | |
| try { | |
| const form = new FormData(); | |
| form.append('user_id', userId); | |
| form.append('doc_type', mapFileTypeToDocType(target.type)); | |
| form.append('file', target.file); | |
| const r = await apiPostForm<UploadApiResp>('/api/upload', form); | |
| if (!r.ok) throw new Error('Upload response ok=false'); | |
| setUploadedFiles((prev) => | |
| prev.map((x, i) => (i === index ? { ...x, uploaded: true, uploadedChunks: r.added_chunks } : x)) | |
| ); | |
| toast.success(`Uploaded: ${target.file.name} (+${r.added_chunks} chunks)`); | |
| } catch (e: any) { | |
| console.error(e); | |
| toast.error(`Upload failed: ${e?.message || 'unknown error'}`); | |
| } | |
| }; | |
| const handleUploadAllPending = async () => { | |
| if (!isLoggedIn) { | |
| toast.error('Please log in first'); | |
| return; | |
| } | |
| const pendingIdx = uploadedFiles | |
| .map((f, i) => ({ f, i })) | |
| .filter((x) => !x.f.uploaded) | |
| .map((x) => x.i); | |
| if (!pendingIdx.length) { | |
| toast.message('No pending files to upload.'); | |
| return; | |
| } | |
| for (const idx of pendingIdx) { | |
| // eslint-disable-next-line no-await-in-loop | |
| await handleUploadSingle(idx); | |
| } | |
| }; | |
| const handleClearConversation = () => { | |
| setMessages([ | |
| { | |
| id: '1', | |
| role: 'assistant', | |
| content: | |
| "Hi! I'm Clare, your AI teaching assistant for Module 10 – Responsible AI. Please log in on the right, upload materials (optional), and ask me anything.", | |
| timestamp: new Date(), | |
| }, | |
| ]); | |
| }; | |
| const handleExport = async () => { | |
| if (!isLoggedIn) { | |
| toast.error('Please log in first'); | |
| return; | |
| } | |
| try { | |
| const r = await apiPostJson<{ markdown: string }>('/api/export', { | |
| user_id: userId, | |
| learning_mode: learningMode, | |
| }); | |
| setExportResult(r.markdown || ''); | |
| setResultType('export'); | |
| toast.success('Conversation exported!'); | |
| } catch (e: any) { | |
| toast.error(`Export failed: ${e?.message || 'unknown error'}`); | |
| } | |
| }; | |
| const handleSummary = async () => { | |
| if (!isLoggedIn) { | |
| toast.error('Please log in first'); | |
| return; | |
| } | |
| try { | |
| const r = await apiPostJson<{ markdown: string }>('/api/summary', { | |
| user_id: userId, | |
| learning_mode: learningMode, | |
| language_preference: language === 'auto' ? 'Auto' : language, | |
| }); | |
| setExportResult(r.markdown || ''); | |
| setResultType('summary'); | |
| toast.success('Summary generated!'); | |
| } catch (e: any) { | |
| toast.error(`Summary failed: ${e?.message || 'unknown error'}`); | |
| } | |
| }; | |
| const handleQuiz = () => { | |
| const quiz = `# Micro-Quiz: Responsible AI | |
| 1) Which is a key principle of Responsible AI? | |
| A) Profit maximization | |
| B) Transparency | |
| C) Rapid deployment | |
| D) Cost reduction | |
| `; | |
| setExportResult(quiz); | |
| setResultType('quiz'); | |
| toast.success('Quiz generated!'); | |
| }; | |
| useEffect(() => { | |
| if (!isLoggedIn) return; | |
| const run = async () => { | |
| try { | |
| const res = await fetch(`/api/memoryline?user_id=${encodeURIComponent(userId)}`); | |
| if (!res.ok) return; | |
| const j = await res.json(); | |
| const pct = typeof j?.progress_pct === 'number' ? j.progress_pct : null; | |
| if (pct !== null) setMemoryProgress(Math.round(pct * 100)); | |
| } catch { | |
| // ignore | |
| } | |
| }; | |
| run(); | |
| }, [isLoggedIn, userId]); | |
| return ( | |
| <div className="min-h-screen bg-background flex flex-col"> | |
| <Toaster /> | |
| <Header | |
| user={user} | |
| onMenuClick={() => setLeftSidebarOpen(!leftSidebarOpen)} | |
| onUserClick={() => setRightPanelOpen(!rightPanelOpen)} | |
| isDarkMode={isDarkMode} | |
| onToggleDarkMode={() => setIsDarkMode(!isDarkMode)} | |
| /> | |
| <div className="flex-1 flex overflow-hidden"> | |
| {leftSidebarOpen && ( | |
| <div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setLeftSidebarOpen(false)} /> | |
| )} | |
| <aside | |
| className={` | |
| fixed lg:static inset-y-0 left-0 z-50 | |
| w-80 bg-card border-r border-border | |
| transform transition-transform duration-300 ease-in-out | |
| ${leftSidebarOpen ? 'translate-x-0' : '-translate-x-full'} | |
| lg:translate-x-0 | |
| flex flex-col | |
| mt-16 lg:mt-0 | |
| `} | |
| > | |
| <div className="lg:hidden p-4 border-b border-border flex justify-between items-center"> | |
| <h3>Settings & Guide</h3> | |
| <Button variant="ghost" size="icon" onClick={() => setLeftSidebarOpen(false)}> | |
| <X className="h-5 w-5" /> | |
| </Button> | |
| </div> | |
| <LeftSidebar | |
| learningMode={learningMode} | |
| language={language} | |
| onLearningModeChange={setLearningMode} | |
| onLanguageChange={setLanguage} | |
| spaceType={spaceType} | |
| onSpaceTypeChange={setSpaceType} | |
| groupMembers={groupMembers} | |
| /> | |
| </aside> | |
| {/* ✅ 右侧 Panel 展开时,为 Chat 留出空间(避免被遮住导致 FAB 只能在收起时可见) */} | |
| <main | |
| className={` | |
| flex-1 flex flex-col min-w-0 | |
| transition-all | |
| ${rightPanelVisible ? 'pr-[320px]' : ''} | |
| `} | |
| > | |
| <ChatArea | |
| userId={userId} | |
| docType={currentDocTypeForChat} | |
| messages={messages} | |
| onSendMessage={handleSendMessage} | |
| uploadedFiles={uploadedFiles} | |
| onFileUpload={handleFileUpload} | |
| onRemoveFile={handleRemoveFile} | |
| onFileTypeChange={handleFileTypeChange} | |
| onUploadFile={handleUploadSingle} | |
| onUploadAll={handleUploadAllPending} | |
| memoryProgress={memoryProgress} | |
| isLoggedIn={isLoggedIn} | |
| learningMode={learningMode} | |
| onClearConversation={handleClearConversation} | |
| onLearningModeChange={setLearningMode} | |
| spaceType={spaceType} | |
| /> | |
| </main> | |
| {rightPanelOpen && ( | |
| <div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setRightPanelOpen(false)} /> | |
| )} | |
| {rightPanelVisible && ( | |
| <aside | |
| className={` | |
| fixed lg:static inset-y-0 right-0 z-50 | |
| w-80 bg-card border-l border-border | |
| transform transition-transform duration-300 ease-in-out | |
| ${rightPanelOpen ? 'translate-x-0' : 'translate-x-full'} | |
| lg:translate-x-0 | |
| flex flex-col | |
| mt-16 lg:mt-0 | |
| `} | |
| > | |
| <div className="lg:hidden p-4 border-b border-border flex justify-between items-center"> | |
| <h3>Account & Actions</h3> | |
| <Button variant="ghost" size="icon" onClick={() => setRightPanelOpen(false)}> | |
| <X className="h-5 w-5" /> | |
| </Button> | |
| </div> | |
| <RightPanel | |
| user={user} | |
| onLogin={handleLogin} | |
| onLogout={handleLogout} | |
| isLoggedIn={isLoggedIn} | |
| onClose={() => setRightPanelVisible(false)} | |
| exportResult={exportResult} | |
| setExportResult={setExportResult} | |
| resultType={resultType} | |
| setResultType={setResultType} | |
| onExport={handleExport} | |
| onQuiz={handleQuiz} | |
| onSummary={handleSummary} | |
| /> | |
| </aside> | |
| )} | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| onClick={() => setRightPanelVisible(!rightPanelVisible)} | |
| className={`hidden lg:flex fixed top-20 z-[70] h-8 w-5 shadow-lg transition-all rounded-l-full rounded-r-none border-r-0 ${ | |
| rightPanelVisible ? 'right-[320px]' : 'right-0' | |
| }`} | |
| title={rightPanelVisible ? 'Close panel' : 'Open panel'} | |
| > | |
| {rightPanelVisible ? <ChevronRight className="h-3 w-3" /> : <ChevronLeft className="h-3 w-3" />} | |
| </Button> | |
| {/* ✅ 右侧 Panel 收起时才显示 FloatingActionButtons(你当前逻辑保持不变) */} | |
| {!rightPanelVisible && ( | |
| <FloatingActionButtons | |
| user={user} | |
| isLoggedIn={isLoggedIn} | |
| onOpenPanel={() => setRightPanelVisible(true)} | |
| onExport={handleExport} | |
| onQuiz={handleQuiz} | |
| onSummary={handleSummary} | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default App; | |