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; // For group chat } export interface User { name: string; email: string; } 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; }; 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. Upload your materials (e.g., syllabus/lecture slides) 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]); const userId = useMemo(() => { // 登录时:email 作为 user_id;不登录也可跑通:0405 return (user?.email || '0405').trim(); }, [user]); const isLoggedIn = useMemo(() => { // 你如果希望“必须登录才能聊天/上传”,这里改回 !!user // 目前为了便于调试后端,允许未登录也走通(user_id=0405) return true; }, []); 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]); const handleSendMessage = async (content: string) => { if (!content.trim()) return; const shouldAIRespond = spaceType === 'individual' || content.toLowerCase().includes('@clare'); const sender: GroupMember | undefined = spaceType === 'group' && user ? { id: user.email, 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[]) => { 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))); }; // ✅ 上传单个文件(关键:读取“最新的 type”,写入 doc_type) const handleUploadSingle = async (index: number) => { 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 () => { 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) { // 顺序上传便于 debug // 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. Upload your materials (e.g., syllabus/lecture slides) and ask me anything.", timestamp: new Date(), }, ]); }; const handleExport = async () => { 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 () => { 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(() => { 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(); }, [userId]); return (
setLeftSidebarOpen(!leftSidebarOpen)} onUserClick={() => setRightPanelOpen(!rightPanelOpen)} isDarkMode={isDarkMode} onToggleDarkMode={() => setIsDarkMode(!isDarkMode)} />
{leftSidebarOpen && (
setLeftSidebarOpen(false)} /> )}
{rightPanelOpen && (
setRightPanelOpen(false)} /> )} {rightPanelVisible && ( )} {!rightPanelVisible && ( setRightPanelVisible(true)} onExport={handleExport} onQuiz={handleQuiz} onSummary={handleSummary} /> )}
); } export default App;