Spaces:
Sleeping
Sleeping
| // web/src/components/TeacherChatPage.tsx | |
| // 教师模块专属聊天页面(支持文件上传和对话) | |
| import React, { useState, useRef, useEffect } from "react"; | |
| import { Button } from "./ui/button"; | |
| import { Input } from "./ui/input"; | |
| import { Textarea } from "./ui/textarea"; | |
| import { Label } from "./ui/label"; | |
| import { Card } from "./ui/card"; | |
| import { ArrowLeft, Send, Upload, X, Loader2, FileText, Image as ImageIcon } from "lucide-react"; | |
| import { toast } from "sonner"; | |
| import { apiUpload } from "../lib/api"; | |
| import type { FeatureId } from "./TeacherDashboard"; | |
| export interface Message { | |
| id: string; | |
| role: "user" | "assistant"; | |
| content: string; | |
| timestamp: Date; | |
| files?: { name: string; type: string }[]; | |
| } | |
| type FileType = "syllabus" | "lecture-slides" | "literature-review" | "other"; | |
| interface UploadedFile { | |
| file: File; | |
| type: FileType; | |
| } | |
| interface TeacherChatPageProps { | |
| featureId: FeatureId; | |
| featureTitle: string; | |
| featureDesc: string; | |
| initialInputs?: Record<string, string>; | |
| onBack: () => void; | |
| userId: string; | |
| replyLanguage?: "zh" | "en" | "auto"; | |
| onSubmit: (inputs: Record<string, string>, files: File[], history?: Array<[string, string]>) => Promise<string>; | |
| } | |
| const DOC_TYPE_MAP: Record<FileType, string> = { | |
| syllabus: "Syllabus", | |
| "lecture-slides": "Lecture Slides / PPT", | |
| "literature-review": "Literature Review / Paper", | |
| other: "Other Course Document", | |
| }; | |
| export function TeacherChatPage({ | |
| featureId, | |
| featureTitle, | |
| featureDesc, | |
| initialInputs = {}, | |
| onBack, | |
| userId, | |
| replyLanguage = "en", | |
| onSubmit, | |
| }: TeacherChatPageProps) { | |
| const [messages, setMessages] = useState<Message[]>([]); | |
| const [inputValues, setInputValues] = useState<Record<string, string>>(initialInputs); | |
| const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]); | |
| const [pendingFiles, setPendingFiles] = useState<File[]>([]); | |
| const [isSubmitting, setIsSubmitting] = useState(false); | |
| const [showFileTypeDialog, setShowFileTypeDialog] = useState(false); | |
| const [chatInput, setChatInput] = useState(""); | |
| const [isInitialSubmit, setIsInitialSubmit] = useState(true); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const chatInputRef = useRef<HTMLTextAreaElement>(null); | |
| useEffect(() => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| }, [messages]); | |
| const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const files = Array.from(e.target.files || []); | |
| if (files.length === 0) return; | |
| const validFiles = files.filter((file) => { | |
| const ext = file.name.toLowerCase(); | |
| return [".pdf", ".docx", ".pptx", ".doc", ".ppt", ".jpg", ".jpeg", ".png", ".gif", ".webp"].some((allowed) => | |
| ext.endsWith(allowed) | |
| ); | |
| }); | |
| if (validFiles.length === 0) { | |
| toast.error("Please upload .pdf, .docx, .pptx, or image files"); | |
| e.target.value = ""; | |
| return; | |
| } | |
| setPendingFiles(validFiles); | |
| setShowFileTypeDialog(true); | |
| e.target.value = ""; | |
| }; | |
| const handleConfirmFileUpload = async () => { | |
| const filesToUpload = pendingFiles; | |
| setPendingFiles([]); | |
| setShowFileTypeDialog(false); | |
| const newFiles: UploadedFile[] = filesToUpload.map((file) => ({ file, type: "other" as FileType })); | |
| setUploadedFiles((prev) => [...prev, ...newFiles]); | |
| // Upload to backend | |
| for (const file of filesToUpload) { | |
| try { | |
| await apiUpload({ | |
| user_id: userId, | |
| doc_type: DOC_TYPE_MAP["other"], | |
| file, | |
| }); | |
| toast.success(`File uploaded: ${file.name}`); | |
| } catch (e: any) { | |
| toast.error(e?.message || `Upload failed: ${file.name}`); | |
| } | |
| } | |
| }; | |
| const handleRemoveFile = (index: number) => { | |
| setUploadedFiles((prev) => prev.filter((_, i) => i !== index)); | |
| }; | |
| const handleSubmit = async () => { | |
| if (isSubmitting) return; | |
| // Validate required inputs for initial submit | |
| if (isInitialSubmit) { | |
| const requiredFields: Record<FeatureId, string[]> = { | |
| "course-description": ["topic"], | |
| "doc-suggestion": ["topic"], | |
| "assignment-questions": ["topic"], | |
| "assessment-analysis": ["summary"], | |
| vision: ["courseInfo", "syllabus"], | |
| activities: ["topic"], | |
| copilot: ["content"], | |
| "qa-optimize": ["summary"], | |
| content: ["topic"], | |
| }; | |
| const required = requiredFields[featureId] || []; | |
| const missing = required.filter((field) => !inputValues[field]?.trim()); | |
| if (missing.length > 0) { | |
| toast.error(`Please fill in required fields: ${missing.join(", ")}`); | |
| return; | |
| } | |
| } | |
| setIsSubmitting(true); | |
| // Build user message content | |
| let userContent: string; | |
| if (isInitialSubmit) { | |
| // Initial submit: use form inputs | |
| userContent = Object.entries(inputValues) | |
| .filter(([_, v]) => v?.trim()) | |
| .map(([k, v]) => `${k}: ${v}`) | |
| .join("\n"); | |
| } else { | |
| // Continuous chat: use chat input | |
| if (!chatInput.trim()) { | |
| setIsSubmitting(false); | |
| return; | |
| } | |
| userContent = chatInput.trim(); | |
| } | |
| // Add user message | |
| const userMessage: Message = { | |
| id: Date.now().toString(), | |
| role: "user", | |
| content: userContent, | |
| timestamp: new Date(), | |
| files: isInitialSubmit ? uploadedFiles.map((uf) => ({ name: uf.file.name, type: uf.file.type })) : undefined, | |
| }; | |
| setMessages((prev) => [...prev, userMessage]); | |
| // Build conversation history (excluding the current message we're about to add) | |
| const history: Array<[string, string]> = []; | |
| for (let i = 0; i < messages.length; i++) { | |
| if (messages[i].role === "user" && i + 1 < messages.length && messages[i + 1].role === "assistant") { | |
| history.push([messages[i].content, messages[i + 1].content]); | |
| } | |
| } | |
| try { | |
| // For continuous chat, use chat input as the main message | |
| // but keep form inputs for context | |
| const inputsForApi = isInitialSubmit | |
| ? inputValues | |
| : { ...inputValues, userMessage: chatInput.trim() }; | |
| const result = await onSubmit(inputsForApi, uploadedFiles.map((uf) => uf.file), history); | |
| // Add assistant message | |
| const assistantMessage: Message = { | |
| id: (Date.now() + 1).toString(), | |
| role: "assistant", | |
| content: result, | |
| timestamp: new Date(), | |
| }; | |
| setMessages((prev) => [...prev, assistantMessage]); | |
| setIsInitialSubmit(false); | |
| setChatInput(""); | |
| } catch (e: any) { | |
| toast.error(e?.message || "Generation failed"); | |
| } finally { | |
| setIsSubmitting(false); | |
| } | |
| }; | |
| const handleChatSubmit = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| await handleSubmit(); | |
| }; | |
| const getInputFields = () => { | |
| switch (featureId) { | |
| case "course-description": | |
| return ( | |
| <> | |
| <div> | |
| <Label>Course Topic / Name *</Label> | |
| <Input | |
| placeholder="e.g., Retrieval-Augmented Generation (RAG)" | |
| value={inputValues.topic || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, topic: e.target.value })} | |
| className="mt-1" | |
| /> | |
| </div> | |
| <div> | |
| <Label>Outline or Key Points (Optional)</Label> | |
| <Textarea | |
| placeholder="Paste existing outline points" | |
| value={inputValues.outline || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, outline: e.target.value })} | |
| rows={3} | |
| className="mt-1" | |
| /> | |
| </div> | |
| </> | |
| ); | |
| case "doc-suggestion": | |
| return ( | |
| <> | |
| <div> | |
| <Label>Topic or Chapter *</Label> | |
| <Input | |
| placeholder="e.g., Prompt Engineering Core Principles" | |
| value={inputValues.topic || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, topic: e.target.value })} | |
| className="mt-1" | |
| /> | |
| </div> | |
| <div> | |
| <Label>Current Content Excerpt (Optional)</Label> | |
| <Textarea | |
| placeholder="Paste existing lecture notes" | |
| value={inputValues.excerpt || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, excerpt: e.target.value })} | |
| rows={3} | |
| className="mt-1" | |
| /> | |
| </div> | |
| <div> | |
| <Label>Document Type</Label> | |
| <Input | |
| placeholder="Lecture Notes / Slides" | |
| value={inputValues.docType || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, docType: e.target.value })} | |
| className="mt-1" | |
| /> | |
| </div> | |
| </> | |
| ); | |
| case "assignment-questions": | |
| return ( | |
| <> | |
| <div> | |
| <Label>Topic *</Label> | |
| <Input | |
| placeholder="e.g., RAG and Vector Retrieval" | |
| value={inputValues.topic || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, topic: e.target.value })} | |
| className="mt-1" | |
| /> | |
| </div> | |
| <div> | |
| <Label>Week / Module (Optional)</Label> | |
| <Input | |
| placeholder="e.g., Week 7" | |
| value={inputValues.week || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, week: e.target.value })} | |
| className="mt-1" | |
| /> | |
| </div> | |
| <div> | |
| <Label>Question Type Preference</Label> | |
| <Input | |
| placeholder="Multiple choice, Short answer, Open-ended, Mixed" | |
| value={inputValues.questionType || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, questionType: e.target.value })} | |
| className="mt-1" | |
| /> | |
| </div> | |
| </> | |
| ); | |
| case "assessment-analysis": | |
| return ( | |
| <> | |
| <div> | |
| <Label>Student Assessment Summary *</Label> | |
| <Textarea | |
| placeholder="e.g., This week's assignment score distribution, common errors, participation summary..." | |
| value={inputValues.summary || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, summary: e.target.value })} | |
| rows={4} | |
| className="mt-1" | |
| /> | |
| </div> | |
| <div> | |
| <Label>Related Course Topic (Optional)</Label> | |
| <Input | |
| placeholder="To help analyze with knowledge base" | |
| value={inputValues.topic || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, topic: e.target.value })} | |
| className="mt-1" | |
| /> | |
| </div> | |
| </> | |
| ); | |
| case "vision": | |
| return ( | |
| <> | |
| <div> | |
| <Label>Course Basic Information *</Label> | |
| <Textarea | |
| placeholder="Course name, target audience, prerequisites, etc." | |
| value={inputValues.courseInfo || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, courseInfo: e.target.value })} | |
| rows={3} | |
| className="mt-1" | |
| /> | |
| </div> | |
| <div> | |
| <Label>Syllabus or Key Points *</Label> | |
| <Textarea | |
| placeholder="Paste syllabus or chapter points" | |
| value={inputValues.syllabus || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, syllabus: e.target.value })} | |
| rows={4} | |
| className="mt-1" | |
| /> | |
| </div> | |
| </> | |
| ); | |
| case "activities": | |
| return ( | |
| <> | |
| <div> | |
| <Label>Topic / Module *</Label> | |
| <Input | |
| placeholder="e.g., RAG Retrieval and Ranking" | |
| value={inputValues.topic || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, topic: e.target.value })} | |
| className="mt-1" | |
| /> | |
| </div> | |
| <div> | |
| <Label>Learning Objectives (Optional)</Label> | |
| <Textarea | |
| placeholder="Objectives students should achieve in this module" | |
| value={inputValues.objectives || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, objectives: e.target.value })} | |
| rows={3} | |
| className="mt-1" | |
| /> | |
| </div> | |
| </> | |
| ); | |
| case "copilot": | |
| return ( | |
| <> | |
| <div> | |
| <Label>Current Teaching Content / Question *</Label> | |
| <Textarea | |
| placeholder="Content being taught or student questions" | |
| value={inputValues.content || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, content: e.target.value })} | |
| rows={4} | |
| className="mt-1" | |
| /> | |
| </div> | |
| <div> | |
| <Label>Student Profiles (Optional, one per line)</Label> | |
| <Textarea | |
| placeholder="Name: Progress: Behavior Summary" | |
| value={inputValues.profiles || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, profiles: e.target.value })} | |
| rows={3} | |
| className="mt-1" | |
| /> | |
| </div> | |
| </> | |
| ); | |
| case "qa-optimize": | |
| return ( | |
| <> | |
| <div> | |
| <Label>Student Quiz Data Summary *</Label> | |
| <Textarea | |
| placeholder="Smart Quiz answer statistics, error distribution, etc." | |
| value={inputValues.summary || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, summary: e.target.value })} | |
| rows={4} | |
| className="mt-1" | |
| /> | |
| </div> | |
| <div> | |
| <Label>Related Course Topic (Optional)</Label> | |
| <Input | |
| placeholder="To help analyze with knowledge base" | |
| value={inputValues.topic || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, topic: e.target.value })} | |
| className="mt-1" | |
| /> | |
| </div> | |
| </> | |
| ); | |
| case "content": | |
| return ( | |
| <> | |
| <div> | |
| <Label>Topic / Chapter *</Label> | |
| <Input | |
| placeholder="e.g., Prompt Engineering Basics" | |
| value={inputValues.topic || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, topic: e.target.value })} | |
| className="mt-1" | |
| /> | |
| </div> | |
| <div> | |
| <Label>Class Duration (Optional)</Label> | |
| <Input | |
| placeholder="e.g., 45 minutes" | |
| value={inputValues.duration || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, duration: e.target.value })} | |
| className="mt-1" | |
| /> | |
| </div> | |
| <div> | |
| <Label>Outline Points (Optional)</Label> | |
| <Textarea | |
| placeholder="Key points to cover in this section" | |
| value={inputValues.outline || ""} | |
| onChange={(e) => setInputValues({ ...inputValues, outline: e.target.value })} | |
| rows={3} | |
| className="mt-1" | |
| /> | |
| </div> | |
| </> | |
| ); | |
| default: | |
| return null; | |
| } | |
| }; | |
| return ( | |
| <div className="h-full flex flex-col bg-background"> | |
| {/* Header */} | |
| <div className="flex-shrink-0 flex items-center gap-4 px-4 py-3 border-b border-border"> | |
| <Button variant="ghost" size="icon" onClick={onBack}> | |
| <ArrowLeft className="h-5 w-5" /> | |
| </Button> | |
| <div className="flex-1"> | |
| <h1 className="text-lg font-semibold">{featureTitle}</h1> | |
| <p className="text-sm text-muted-foreground">{featureDesc}</p> | |
| </div> | |
| </div> | |
| <div className="flex-1 flex overflow-hidden"> | |
| {/* Left Panel: Input Form */} | |
| <div className="w-96 border-r border-border overflow-auto p-4 space-y-4"> | |
| <Card className="p-4"> | |
| <h3 className="font-medium mb-3">Input Parameters</h3> | |
| <div className="space-y-3">{getInputFields()}</div> | |
| </Card> | |
| {/* File Upload */} | |
| <Card className="p-4"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <h3 className="font-medium">Upload Files</h3> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => fileInputRef.current?.click()} | |
| className="gap-2" | |
| > | |
| <Upload className="h-4 w-4" /> | |
| Upload | |
| </Button> | |
| </div> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| multiple | |
| className="hidden" | |
| accept=".pdf,.docx,.pptx,.doc,.ppt,.jpg,.jpeg,.png,.gif,.webp" | |
| onChange={handleFileSelect} | |
| /> | |
| {uploadedFiles.length > 0 && ( | |
| <div className="space-y-2 mt-3"> | |
| {uploadedFiles.map((uf, idx) => ( | |
| <div key={idx} className="flex items-center gap-2 p-2 bg-muted rounded text-sm"> | |
| {uf.file.type.startsWith("image/") ? ( | |
| <ImageIcon className="h-4 w-4 flex-shrink-0" /> | |
| ) : ( | |
| <FileText className="h-4 w-4 flex-shrink-0" /> | |
| )} | |
| <span className="flex-1 truncate">{uf.file.name}</span> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="h-6 w-6" | |
| onClick={() => handleRemoveFile(idx)} | |
| > | |
| <X className="h-3 w-3" /> | |
| </Button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </Card> | |
| <Button onClick={handleSubmit} disabled={isSubmitting} className="w-full" size="lg"> | |
| {isSubmitting ? ( | |
| <> | |
| <Loader2 className="h-4 w-4 animate-spin mr-2" /> | |
| Generating... | |
| </> | |
| ) : ( | |
| <> | |
| <Send className="h-4 w-4 mr-2" /> | |
| Generate | |
| </> | |
| )} | |
| </Button> | |
| </div> | |
| {/* Right Panel: Chat Messages */} | |
| <div className="flex-1 flex flex-col overflow-hidden"> | |
| <div className="flex-1 overflow-auto p-4"> | |
| {messages.length === 0 ? ( | |
| <div className="h-full flex items-center justify-center text-muted-foreground"> | |
| <div className="text-center"> | |
| <p className="text-lg mb-2">Start a conversation</p> | |
| <p className="text-sm">Fill in the form and click Generate to begin</p> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="space-y-4 max-w-3xl mx-auto"> | |
| {messages.map((msg) => ( | |
| <div | |
| key={msg.id} | |
| className={`flex gap-3 ${msg.role === "user" ? "justify-end" : "justify-start"}`} | |
| > | |
| {msg.role === "assistant" && ( | |
| <div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0"> | |
| <FileText className="h-4 w-4 text-primary" /> | |
| </div> | |
| )} | |
| <div | |
| className={`rounded-lg p-4 max-w-[80%] ${ | |
| msg.role === "user" | |
| ? "bg-primary text-primary-foreground" | |
| : "bg-muted text-foreground" | |
| }`} | |
| > | |
| {msg.files && msg.files.length > 0 && ( | |
| <div className="mb-2 pb-2 border-b border-border/50"> | |
| <p className="text-xs opacity-80 mb-1">Attached files:</p> | |
| <div className="flex flex-wrap gap-1"> | |
| {msg.files.map((f, idx) => ( | |
| <span key={idx} className="text-xs bg-background/20 px-2 py-0.5 rounded"> | |
| {f.name} | |
| </span> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| <p className="whitespace-pre-wrap">{msg.content}</p> | |
| <p className="text-xs opacity-70 mt-2"> | |
| {msg.timestamp.toLocaleTimeString()} | |
| </p> | |
| </div> | |
| {msg.role === "user" && ( | |
| <div className="w-8 h-8 rounded-full bg-primary flex items-center justify-center flex-shrink-0"> | |
| <span className="text-xs text-primary-foreground">U</span> | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| )} | |
| </div> | |
| {/* Chat Input */} | |
| {messages.length > 0 && ( | |
| <div className="flex-shrink-0 border-t border-border p-4"> | |
| <form onSubmit={handleChatSubmit} className="flex gap-2 max-w-3xl mx-auto"> | |
| <Textarea | |
| ref={chatInputRef} | |
| value={chatInput} | |
| onChange={(e) => setChatInput(e.target.value)} | |
| placeholder="Continue the conversation..." | |
| className="flex-1 min-h-[60px] max-h-[200px] resize-none" | |
| disabled={isSubmitting} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleChatSubmit(e); | |
| } | |
| }} | |
| /> | |
| <Button type="submit" disabled={isSubmitting || !chatInput.trim()} size="lg"> | |
| {isSubmitting ? ( | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| ) : ( | |
| <Send className="h-4 w-4" /> | |
| )} | |
| </Button> | |
| </form> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* File Type Dialog */} | |
| {showFileTypeDialog && ( | |
| <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> | |
| <Card className="p-6 max-w-md w-full"> | |
| <h3 className="font-medium mb-4">Confirm File Upload</h3> | |
| <div className="space-y-2 mb-4"> | |
| {pendingFiles.map((file, idx) => ( | |
| <div key={idx} className="text-sm p-2 bg-muted rounded"> | |
| {file.name} | |
| </div> | |
| ))} | |
| </div> | |
| <div className="flex gap-2"> | |
| <Button onClick={handleConfirmFileUpload} className="flex-1"> | |
| Upload | |
| </Button> | |
| <Button | |
| variant="outline" | |
| onClick={() => { | |
| setPendingFiles([]); | |
| setShowFileTypeDialog(false); | |
| }} | |
| > | |
| Cancel | |
| </Button> | |
| </div> | |
| </Card> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |