ClareCourseWare / web /src /components /TeacherChatPage.tsx
claudqunwang's picture
添加持续对话功能:支持每个功能模块与AI进行多轮对话和互动
6a91b07
// 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>
);
}