SarahXia0405's picture
Update web/src/App.tsx
a18d7db verified
// 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;