AI_Agent_V4 / web /src /App.tsx
SarahXia0405's picture
Update web/src/App.tsx
a720e8f verified
// web/src/App.tsx
import React, { useState, useEffect } 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 { LoginScreen } from './components/LoginScreen';
import { ProfileEditor } from './components/ProfileEditor';
import { X } from 'lucide-react';
import { Button } from './components/ui/button';
import { Toaster } from './components/ui/sonner';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import {
apiLogin,
apiChat,
apiUpload,
apiExport,
apiSummary,
type LearningMode,
type Language,
type FileType,
type User as ApiUser,
} from './lib/api';
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 interface Workspace {
id: string;
name: string;
type: SpaceType;
avatar: string;
members?: GroupMember[];
}
export interface UploadedFile {
file: File;
type: FileType;
}
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. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!",
timestamp: new Date(),
},
]);
const [learningMode, setLearningMode] = useState<LearningMode>('concept');
const [language, setLanguage] = useState<Language>('auto');
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
// You can later wire this to /api/memoryline
const [memoryProgress] = useState(36);
const [leftSidebarOpen, setLeftSidebarOpen] = useState(false);
const [leftPanelVisible, setLeftPanelVisible] = useState(true);
const [rightPanelOpen, setRightPanelOpen] = useState(false);
const [rightPanelVisible, setRightPanelVisible] = useState(true);
const [showProfileEditor, setShowProfileEditor] = useState(false);
const [exportResult, setExportResult] = useState('');
const [resultType, setResultType] = useState<'export' | 'quiz' | 'summary' | null>(null);
// Mock group members (still fine; AI responder uses backend now)
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' },
]);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string>('individual');
useEffect(() => {
if (user) {
const userAvatar = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`;
setWorkspaces([
{
id: 'individual',
name: 'My Space',
type: 'individual',
avatar: userAvatar,
},
{
id: 'group-1',
name: 'CS 101 Study Group',
type: 'group',
avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=cs101group',
members: groupMembers,
},
{
id: 'group-2',
name: 'AI Ethics Team',
type: 'group',
avatar: 'https://api.dicebear.com/7.x/shapes/svg?seed=aiethicsteam',
members: groupMembers,
},
]);
}
}, [user, groupMembers]);
const currentWorkspace = workspaces.find((w) => w.id === currentWorkspaceId) || workspaces[0];
const spaceType: SpaceType = currentWorkspace?.type || 'individual';
useEffect(() => {
document.documentElement.classList.toggle('dark', isDarkMode);
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
const asApiUser = (u: User): ApiUser => ({ name: u.name, email: u.email });
const handleSendMessage = async (content: string) => {
if (!content.trim() || !user) return;
const sender: GroupMember | undefined =
spaceType === 'group'
? { id: user.email, name: user.name, email: user.email }
: undefined;
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content,
timestamp: new Date(),
sender,
};
setMessages((prev) => [...prev, userMessage]);
const shouldAIRespond = spaceType === 'individual' || content.toLowerCase().includes('@clare');
if (!shouldAIRespond) return;
const assistantId = crypto.randomUUID();
const assistantPlaceholder: Message = {
id: assistantId,
role: 'assistant',
content: 'Thinking...',
timestamp: new Date(),
sender: spaceType === 'group' ? groupMembers.find((m) => m.isAI) : undefined,
};
setMessages((prev) => [...prev, assistantPlaceholder]);
try {
const data = await apiChat({
user: asApiUser(user),
message: content,
learningMode,
language,
docType: 'Syllabus',
});
const references =
(data.refs || [])
.map((r) => [r.source_file, r.section].filter(Boolean).join(' — '))
.filter(Boolean);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
content: data.reply || '',
references: references.length ? references : undefined,
}
: m
)
);
} catch (err: any) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: `Sorry — request failed.\n${err?.message ?? String(err)}` }
: m
)
);
}
};
const handleFileUpload = async (files: File[]) => {
if (!user) return;
const newFiles: UploadedFile[] = files.map((file) => ({
file,
type: 'other',
}));
setUploadedFiles((prev) => [...prev, ...newFiles]);
for (const f of files) {
try {
const r = await apiUpload({ user: asApiUser(user), file: f, fileType: 'other' });
toast.success(r.status_md || `Uploaded: ${f.name}`);
} catch (e: any) {
toast.error(e?.message ?? `Upload failed: ${f.name}`);
}
}
};
const handleRemoveFile = (index: number) => {
setUploadedFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleFileTypeChange = async (index: number, type: FileType) => {
setUploadedFiles((prev) => prev.map((f, i) => (i === index ? { ...f, type } : f)));
if (!user) return;
const target = uploadedFiles[index];
if (!target) return;
try {
const r = await apiUpload({
user: asApiUser(user),
file: target.file,
fileType: type,
});
toast.success(r.status_md || `Updated type: ${target.file.name}`);
} catch (e: any) {
toast.error(e?.message ?? `Failed to update type: ${target.file.name}`);
}
};
const handleClearConversation = () => {
setMessages([
{
id: '1',
role: 'assistant',
content:
"👋 Hi! I'm Clare, your AI teaching assistant. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!",
timestamp: new Date(),
},
]);
toast.success('Conversation cleared');
};
const handleExport = async () => {
if (!user) return;
try {
const r = await apiExport({ user: asApiUser(user), learningMode });
setExportResult(r.markdown || '');
setResultType('export');
toast.success('Conversation exported!');
} catch (e: any) {
toast.error(e?.message ?? 'Export failed');
}
};
const handleSummary = async () => {
if (!user) return;
try {
const r = await apiSummary({ user: asApiUser(user), learningMode, language });
setExportResult(r.markdown || '');
setResultType('summary');
toast.success('Summary generated!');
} catch (e: any) {
toast.error(e?.message ?? 'Summary failed');
}
};
if (!user) {
return (
<LoginScreen
onLogin={async (u) => {
setUser(u);
try {
await apiLogin(asApiUser(u));
} catch (e: any) {
toast.error(e?.message ?? 'Login sync failed');
}
}}
/>
);
}
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)}
language={language}
onLanguageChange={setLanguage}
workspaces={workspaces}
currentWorkspace={currentWorkspace}
onWorkspaceChange={setCurrentWorkspaceId}
/>
{showProfileEditor && user && (
<ProfileEditor user={user} onSave={setUser} onClose={() => setShowProfileEditor(false)} />
)}
<div
className="flex-1 flex overflow-hidden"
onWheel={(e) => e.stopPropagation()}
style={{ overscrollBehavior: 'none' }}
>
{/* Mobile Sidebar Toggle - Left */}
{leftSidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setLeftSidebarOpen(false)}
/>
)}
{/* Left Sidebar */}
{leftPanelVisible ? (
<aside className="hidden lg:flex w-80 bg-card border-r border-border flex-col h-full min-h-0 relative">
<Button
variant="secondary"
size="icon"
onClick={() => setLeftPanelVisible(false)}
className="absolute top-4 z-[70] h-8 w-5 shadow-lg rounded-full bg-card border border-border"
style={{ right: '-10px' }}
title="Close panel"
>
<ChevronLeft className="h-3 w-3" />
</Button>
<LeftSidebar
learningMode={learningMode}
language={language}
onLearningModeChange={setLearningMode}
onLanguageChange={setLanguage}
spaceType={spaceType}
groupMembers={groupMembers}
user={user}
onLogin={setUser}
onLogout={() => setUser(null)}
isLoggedIn={!!user}
onEditProfile={() => setShowProfileEditor(true)}
/>
</aside>
) : (
<Button
variant="secondary"
size="icon"
onClick={() => setLeftPanelVisible(true)}
className="hidden lg:flex fixed top-20 left-0 z-[70] h-8 w-5 shadow-lg rounded-full bg-card border border-border"
title="Open panel"
>
<ChevronRight className="h-3 w-3" />
</Button>
)}
{/* Left Sidebar - Mobile */}
<aside
className={`
fixed lg:hidden 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'}
flex flex-col
mt-16
h-[calc(100vh-4rem)]
min-h-0
`}
>
<div className="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}
groupMembers={groupMembers}
user={user}
onLogin={setUser}
onLogout={() => setUser(null)}
isLoggedIn={!!user}
onEditProfile={() => setShowProfileEditor(true)}
/>
</aside>
{/* Main Chat Area */}
<main className="flex-1 flex flex-col min-w-0 min-h-0 h-full">
<ChatArea
// ✅ NEW: pass ApiUser down so Message can submit feedback
user={asApiUser(user)}
messages={messages}
onSendMessage={handleSendMessage}
uploadedFiles={uploadedFiles}
onFileUpload={handleFileUpload}
onRemoveFile={handleRemoveFile}
onFileTypeChange={handleFileTypeChange}
memoryProgress={memoryProgress}
isLoggedIn={!!user}
learningMode={learningMode}
onClearConversation={handleClearConversation}
onLearningModeChange={setLearningMode}
spaceType={spaceType}
/>
</main>
{/* Mobile Sidebar Toggle - Right */}
{rightPanelOpen && (
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setRightPanelOpen(false)} />
)}
{/* Right Panel */}
{rightPanelVisible ? (
<aside className="hidden lg:flex w-80 bg-card border-l border-border flex-col min-h-0 relative" style={{ height: 'calc(100vh - 4rem)' }}>
<Button
variant="secondary"
size="icon"
onClick={() => setRightPanelVisible(false)}
className="absolute top-4 z-[70] h-8 w-5 shadow-lg rounded-full bg-card border border-border"
style={{ left: '-10px' }}
title="Close panel"
>
<ChevronRight className="h-3 w-3" />
</Button>
<RightPanel
user={user}
onLogin={setUser}
onLogout={() => setUser(null)}
isLoggedIn={!!user}
onClose={() => setRightPanelVisible(false)}
exportResult={exportResult}
setExportResult={setExportResult}
resultType={resultType}
setResultType={setResultType}
onExport={handleExport}
onSummary={handleSummary}
/>
</aside>
) : (
<Button
variant="secondary"
size="icon"
onClick={() => setRightPanelVisible(true)}
className="hidden lg:flex fixed top-20 right-0 z-[70] h-8 w-5 shadow-lg rounded-full bg-card border border-border"
title="Open panel"
>
<ChevronLeft className="h-3 w-3" />
</Button>
)}
{/* Right Panel - Mobile */}
<aside
className={`
fixed lg:hidden 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'}
flex flex-col
mt-16
h-[calc(100vh-4rem)]
min-h-0
`}
>
<div className="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={setUser}
onLogout={() => setUser(null)}
isLoggedIn={!!user}
onClose={() => setRightPanelVisible(false)}
exportResult={exportResult}
setExportResult={setExportResult}
resultType={resultType}
setResultType={setResultType}
onExport={handleExport}
onSummary={handleSummary}
/>
</aside>
{/* Floating Action Buttons - Desktop only, when panel is closed */}
{!rightPanelVisible && (
<FloatingActionButtons
user={user}
isLoggedIn={!!user}
onOpenPanel={() => setRightPanelVisible(true)}
onExport={handleExport}
onSummary={handleSummary}
/>
)}
</div>
</div>
);
}
export default App;