// 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(path: string, payload: any): Promise { 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(path: string, form: FormData): Promise { 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(null); const [messages, setMessages] = useState([ { 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('concept'); const [language, setLanguage] = useState('auto'); const [uploadedFiles, setUploadedFiles] = useState([]); const [memoryProgress, setMemoryProgress] = useState(40); const [leftSidebarOpen, setLeftSidebarOpen] = useState(false); const [rightPanelOpen, setRightPanelOpen] = useState(false); const [rightPanelVisible, setRightPanelVisible] = useState(true); const [spaceType, setSpaceType] = useState('individual'); const [exportResult, setExportResult] = useState(''); const [resultType, setResultType] = useState<'export' | 'quiz' | 'summary' | null>(null); const [groupMembers] = useState([ { 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('/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('/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('/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 (
setLeftSidebarOpen(!leftSidebarOpen)} onUserClick={() => setRightPanelOpen(!rightPanelOpen)} isDarkMode={isDarkMode} onToggleDarkMode={() => setIsDarkMode(!isDarkMode)} />
{leftSidebarOpen && (
setLeftSidebarOpen(false)} /> )} {/* ✅ 右侧 Panel 展开时,为 Chat 留出空间(避免被遮住导致 FAB 只能在收起时可见) */}
{rightPanelOpen && (
setRightPanelOpen(false)} /> )} {rightPanelVisible && ( )} {/* ✅ 右侧 Panel 收起时才显示 FloatingActionButtons(你当前逻辑保持不变) */} {!rightPanelVisible && ( setRightPanelVisible(true)} onExport={handleExport} onQuiz={handleQuiz} onSummary={handleSummary} /> )}
); } export default App;