diff --git a/web/README.md b/web/README.md deleted file mode 100644 index 01b1cd2d6adbf7aea13c1c69e7ab1208fc2b15ad..0000000000000000000000000000000000000000 --- a/web/README.md +++ /dev/null @@ -1,11 +0,0 @@ - - # Clare AI Tutor UI Redesign (Copy) - - This is a code bundle for Clare AI Tutor UI Redesign (Copy). The original project is available at https://www.figma.com/design/yC1iBsNtBGpBH8sXO43uah/Clare-AI-Tutor-UI-Redesign--Copy-. - - ## Running the code - - Run `npm i` to install the dependencies. - - Run `npm run dev` to start the development server. - \ No newline at end of file diff --git a/web/index.html b/web/index.html deleted file mode 100644 index 603ccaf3717df1d5a4b76ec20d1d9709c23124cf..0000000000000000000000000000000000000000 --- a/web/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - Clare AI Tutor UI Redesign (Copy) - - - -
- - - - \ No newline at end of file diff --git a/web/package.json b/web/package.json deleted file mode 100644 index a2b0cabbb80dd74f16d3d3fbd62baa0b5bfae6eb..0000000000000000000000000000000000000000 --- a/web/package.json +++ /dev/null @@ -1,61 +0,0 @@ - - { - "name": "Clare AI Tutor UI Redesign (Copy)", - "version": "0.1.0", - "private": true, - "dependencies": { - "@radix-ui/react-accordion": "^1.2.3", - "@radix-ui/react-alert-dialog": "^1.1.6", - "@radix-ui/react-aspect-ratio": "^1.1.2", - "@radix-ui/react-avatar": "^1.1.3", - "@radix-ui/react-checkbox": "^1.1.4", - "@radix-ui/react-collapsible": "^1.1.3", - "@radix-ui/react-context-menu": "^2.2.6", - "@radix-ui/react-dialog": "^1.1.6", - "@radix-ui/react-dropdown-menu": "^2.1.6", - "@radix-ui/react-hover-card": "^1.1.6", - "@radix-ui/react-label": "^2.1.2", - "@radix-ui/react-menubar": "^1.1.6", - "@radix-ui/react-navigation-menu": "^1.2.5", - "@radix-ui/react-popover": "^1.1.6", - "@radix-ui/react-progress": "^1.1.2", - "@radix-ui/react-radio-group": "^1.2.3", - "@radix-ui/react-scroll-area": "^1.2.3", - "@radix-ui/react-select": "^2.1.6", - "@radix-ui/react-separator": "^1.1.2", - "@radix-ui/react-slider": "^1.2.3", - "@radix-ui/react-slot": "^1.1.2", - "@radix-ui/react-switch": "^1.1.3", - "@radix-ui/react-tabs": "^1.1.3", - "@radix-ui/react-toggle": "^1.1.2", - "@radix-ui/react-toggle-group": "^1.1.2", - "@radix-ui/react-tooltip": "^1.1.8", - "class-variance-authority": "^0.7.1", - "clsx": "*", - "cmdk": "^1.1.1", - "embla-carousel-react": "^8.6.0", - "input-otp": "^1.4.2", - "lucide-react": "^0.487.0", - "next-themes": "^0.4.6", - "react": "^18.3.1", - "react-day-picker": "^8.10.1", - "react-dom": "^18.3.1", - "react-hook-form": "^7.55.0", - "react-resizable-panels": "^2.1.7", - "react-markdown": "^9.0.1", - "remark-gfm": "^4.0.0", - "recharts": "^2.15.2", - "sonner": "^2.0.3", - "tailwind-merge": "*", - "vaul": "^1.1.2" - }, - "devDependencies": { - "@types/node": "^20.10.0", - "@vitejs/plugin-react-swc": "^3.10.2", - "vite": "6.3.5" - }, - "scripts": { - "dev": "vite", - "build": "vite build" - } - } \ No newline at end of file diff --git a/web/src/.DS_Store b/web/src/.DS_Store deleted file mode 100644 index 70cfb8d2d8d3d43ba7286d6a132d6d051f1a2214..0000000000000000000000000000000000000000 Binary files a/web/src/.DS_Store and /dev/null differ diff --git a/web/src/App.tsx b/web/src/App.tsx deleted file mode 100644 index 07e11c6751670e8842f66daf6011505d8ef64ac8..0000000000000000000000000000000000000000 --- a/web/src/App.tsx +++ /dev/null @@ -1,563 +0,0 @@ -// 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; diff --git a/web/src/Attributions.md b/web/src/Attributions.md deleted file mode 100644 index 9b7cd4e13487db2e20f4f3844255f0b967db8448..0000000000000000000000000000000000000000 --- a/web/src/Attributions.md +++ /dev/null @@ -1,3 +0,0 @@ -This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md). - -This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license). \ No newline at end of file diff --git a/web/src/components/.DS_Store b/web/src/components/.DS_Store deleted file mode 100644 index ead76fb4506471282295b2a388d5e2dd48b760a1..0000000000000000000000000000000000000000 Binary files a/web/src/components/.DS_Store and /dev/null differ diff --git a/web/src/components/ChatArea.tsx b/web/src/components/ChatArea.tsx deleted file mode 100644 index 7d5ceed683a514f7ce7b8616b14aba261fc77d89..0000000000000000000000000000000000000000 --- a/web/src/components/ChatArea.tsx +++ /dev/null @@ -1,282 +0,0 @@ -// web/src/components/ChatArea.tsx -import React, { useState, useRef, useEffect, useMemo } from 'react'; -import { Button } from './ui/button'; -import { Textarea } from './ui/textarea'; -import { Send, ArrowDown, Trash2, Share2 } from 'lucide-react'; -import { Message } from './Message'; -import { FileUploadArea } from './FileUploadArea'; -import { MemoryLine } from './MemoryLine'; -import type { Message as MessageType, LearningMode, UploadedFile, FileType, SpaceType } from '../App'; -import { toast } from 'sonner'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu'; - -interface ChatAreaProps { - messages: MessageType[]; - onSendMessage: (content: string) => void; - - uploadedFiles: UploadedFile[]; - onFileUpload: (files: File[]) => void; - onRemoveFile: (index: number) => void; - onFileTypeChange: (index: number, type: FileType) => void; - - // ✅ feedback 需要 userId - userId?: string; - - // ✅ 由 App.tsx 传入 currentDocTypeForChat - docType?: string; - - memoryProgress: number; - isLoggedIn: boolean; - learningMode: LearningMode; - onClearConversation: () => void; - onLearningModeChange: (mode: LearningMode) => void; - spaceType: SpaceType; -} - -export function ChatArea({ - messages, - onSendMessage, - uploadedFiles, - onFileUpload, - onRemoveFile, - onFileTypeChange, - userId, - docType = 'Other', - memoryProgress, - isLoggedIn, - learningMode, - onClearConversation, - onLearningModeChange, - spaceType, -}: ChatAreaProps) { - const [input, setInput] = useState(''); - const [isTyping, setIsTyping] = useState(false); - const [showScrollButton, setShowScrollButton] = useState(false); - const messagesEndRef = useRef(null); - const scrollContainerRef = useRef(null); - - const lastUserMessageContent = useMemo(() => { - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === 'user' && messages[i].content?.trim()) return messages[i].content; - } - return ''; - }, [messages]); - - const scrollToBottom = () => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }; - - useEffect(() => { - scrollToBottom(); - }, [messages]); - - useEffect(() => { - const handleScroll = () => { - if (!scrollContainerRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; - setShowScrollButton(scrollHeight - scrollTop - clientHeight > 100); - }; - - const container = scrollContainerRef.current; - container?.addEventListener('scroll', handleScroll); - return () => container?.removeEventListener('scroll', handleScroll); - }, []); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!input.trim() || !isLoggedIn) return; - - onSendMessage(input); - setInput(''); - setIsTyping(true); - setTimeout(() => setIsTyping(false), 1200); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSubmit(e); - } - }; - - const modeLabels: Record = { - concept: 'Concept Explainer', - socratic: 'Socratic Tutor', - exam: 'Exam Prep', - assignment: 'Assignment Helper', - summary: 'Quick Summary', - }; - - const handleClearClick = () => { - if (messages.length <= 1) { - toast.info('No conversation to clear'); - return; - } - if (window.confirm('Are you sure you want to clear the conversation? This cannot be undone.')) { - onClearConversation(); - toast.success('Conversation cleared'); - } - }; - - const handleShareClick = () => { - if (messages.length <= 1) { - toast.info('No conversation to share'); - return; - } - const conversationText = messages - .map((msg) => `${msg.role === 'user' ? 'You' : 'Clare'}: ${msg.content}`) - .join('\n\n'); - - navigator.clipboard - .writeText(conversationText) - .then(() => toast.success('Conversation copied to clipboard!')) - .catch(() => toast.error('Failed to copy conversation')); - }; - - return ( -
-
- {messages.length > 1 && ( -
- - -
- )} - -
-
- {messages.map((m) => ( - - ))} - - {isTyping && ( -
-
- C -
-
-
-
-
-
-
-
-
- )} - -
-
-
- - {showScrollButton && ( -
- -
- )} - -
-
-
-
- - - - - - {(['concept', 'socratic', 'exam', 'assignment', 'summary'] as LearningMode[]).map((mode) => ( - onLearningModeChange(mode)} - className={learningMode === mode ? 'bg-accent' : ''} - > - {modeLabels[mode]} - - ))} - - - -