dvc890 commited on
Commit
ac199b0
·
verified ·
1 Parent(s): 212d1dd

Upload 53 files

Browse files
Files changed (2) hide show
  1. ai-routes.js +11 -2
  2. pages/AIAssistant.tsx +61 -23
ai-routes.js CHANGED
@@ -262,7 +262,7 @@ router.post('/chat', checkAIAccess, async (req, res) => {
262
 
263
  // STREAMING ASSESSMENT ENDPOINT
264
  router.post('/evaluate', checkAIAccess, async (req, res) => {
265
- const { question, audio, image } = req.body;
266
  res.setHeader('Content-Type', 'text/event-stream');
267
  res.setHeader('Cache-Control', 'no-cache');
268
  res.setHeader('Connection', 'keep-alive');
@@ -276,10 +276,19 @@ router.post('/evaluate', checkAIAccess, async (req, res) => {
276
  evalParts.push({ text: "学生的回答在音频中。" });
277
  evalParts.push({ inlineData: { mimeType: 'audio/webm', data: audio } });
278
  }
279
- if (image) {
 
 
 
 
 
 
 
 
280
  evalParts.push({ text: "学生的回答写在图片中,请识别图片中的文字内容并进行批改。" });
281
  evalParts.push({ inlineData: { mimeType: 'image/jpeg', data: image } });
282
  }
 
283
  // Force structured markdown output for streaming parsing
284
  evalParts.push({ text: `请分析:1. 内容准确性 2. 表达/书写规范。
285
  必须严格按照以下格式输出(不要使用Markdown代码块包裹):
 
262
 
263
  // STREAMING ASSESSMENT ENDPOINT
264
  router.post('/evaluate', checkAIAccess, async (req, res) => {
265
+ const { question, audio, image, images } = req.body;
266
  res.setHeader('Content-Type', 'text/event-stream');
267
  res.setHeader('Cache-Control', 'no-cache');
268
  res.setHeader('Connection', 'keep-alive');
 
276
  evalParts.push({ text: "学生的回答在音频中。" });
277
  evalParts.push({ inlineData: { mimeType: 'audio/webm', data: audio } });
278
  }
279
+
280
+ // Support multiple images
281
+ if (images && Array.isArray(images) && images.length > 0) {
282
+ evalParts.push({ text: "学生的回答写在以下图片中,请识别所有图片中的文字内容并进行批改:" });
283
+ images.forEach(img => {
284
+ if(img) evalParts.push({ inlineData: { mimeType: 'image/jpeg', data: img } });
285
+ });
286
+ } else if (image) {
287
+ // Legacy single image support
288
  evalParts.push({ text: "学生的回答写在图片中,请识别图片中的文字内容并进行批改。" });
289
  evalParts.push({ inlineData: { mimeType: 'image/jpeg', data: image } });
290
  }
291
+
292
  // Force structured markdown output for streaming parsing
293
  evalParts.push({ text: `请分析:1. 内容准确性 2. 表达/书写规范。
294
  必须严格按照以下格式输出(不要使用Markdown代码块包裹):
pages/AIAssistant.tsx CHANGED
@@ -141,7 +141,7 @@ export const AIAssistant: React.FC = () => {
141
  // Assessment State
142
  const [assessmentMode, setAssessmentMode] = useState<'audio' | 'image'>('audio');
143
  const [assessmentTopic, setAssessmentTopic] = useState('请背诵《静夜思》并解释其含义。');
144
- const [selectedImage, setSelectedImage] = useState<File | null>(null);
145
 
146
  // SEPARATED ASSESSMENT PROCESSING STATES
147
  const [isAssessmentRecording, setIsAssessmentRecording] = useState(false);
@@ -399,7 +399,7 @@ export const AIAssistant: React.FC = () => {
399
  };
400
 
401
  // --- STREAMING ASSESSMENT HANDLER ---
402
- const handleAssessmentStreamingSubmit = async ({ audio, image }: { audio?: string, image?: string }) => {
403
  // Reset State
404
  setAssessmentStatus('UPLOADING');
405
  setStreamedAssessment({ transcription: '', feedback: '', score: null, audio: undefined });
@@ -414,7 +414,7 @@ export const AIAssistant: React.FC = () => {
414
  'x-user-role': currentUser?.role || '',
415
  'x-school-id': currentUser?.schoolId || ''
416
  },
417
- body: JSON.stringify({ question: assessmentTopic, audio, image })
418
  });
419
 
420
  if (!response.ok) throw new Error(response.statusText);
@@ -502,18 +502,23 @@ export const AIAssistant: React.FC = () => {
502
  }
503
  };
504
 
505
- const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
506
- if (e.target.files && e.target.files[0]) {
507
- setSelectedImage(e.target.files[0]);
508
  }
509
  };
510
 
 
 
 
 
511
  const confirmImageSubmission = async () => {
512
- if (!selectedImage) return;
513
  setAssessmentStatus('UPLOADING');
514
  try {
515
- const base64 = await fileToBase64(selectedImage);
516
- handleAssessmentStreamingSubmit({ image: base64 });
 
517
  } catch(e) {
518
  setAssessmentStatus('IDLE');
519
  setToast({ show: true, message: '图片上传失败', type: 'error' });
@@ -733,22 +738,55 @@ export const AIAssistant: React.FC = () => {
733
  </button>
734
  ) : (
735
  <div className="w-full">
736
- {!selectedImage ? (
737
- <div className="border-2 border-dashed border-purple-200 rounded-xl p-8 text-center hover:bg-purple-50 transition-colors cursor-pointer relative">
738
- <input type="file" accept="image/*" className="absolute inset-0 opacity-0 cursor-pointer" onChange={handleImageUpload}/>
739
- <ImageIcon className="mx-auto text-purple-300 mb-2" size={40}/>
740
- <p className="text-purple-600 font-bold">点击上传作业图片</p>
741
- <p className="text-xs text-gray-400">支持手写文字识别与批改</p>
742
- </div>
743
- ) : (
744
- <div className="flex flex-col items-center gap-4">
745
- <div className="relative"><img src={URL.createObjectURL(selectedImage)} className="h-40 rounded-lg shadow-sm border"/><button onClick={()=>setSelectedImage(null)} className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1"><X size={14}/></button></div>
746
- <button onClick={confirmImageSubmission} disabled={assessmentStatus !== 'IDLE'} className="px-8 py-2 bg-purple-600 text-white rounded-lg font-bold hover:bg-purple-700 flex items-center gap-2">
747
- {assessmentStatus !== 'IDLE' ? <Loader2 className="animate-spin" size={16}/> : <CheckCircle size={16}/>}
748
- {assessmentStatus === 'UPLOADING' ? '上传中...' : assessmentStatus === 'ANALYZING' ? 'AI 正在分析...' : assessmentStatus === 'TTS' ? '生成语音...' : '开始批改'}
749
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
750
  </div>
751
  )}
 
 
 
 
 
 
 
 
 
 
 
752
  </div>
753
  )}
754
  </div>
 
141
  // Assessment State
142
  const [assessmentMode, setAssessmentMode] = useState<'audio' | 'image'>('audio');
143
  const [assessmentTopic, setAssessmentTopic] = useState('请背诵《静夜思》并解释其含义。');
144
+ const [selectedImages, setSelectedImages] = useState<File[]>([]);
145
 
146
  // SEPARATED ASSESSMENT PROCESSING STATES
147
  const [isAssessmentRecording, setIsAssessmentRecording] = useState(false);
 
399
  };
400
 
401
  // --- STREAMING ASSESSMENT HANDLER ---
402
+ const handleAssessmentStreamingSubmit = async ({ audio, images }: { audio?: string, images?: string[] }) => {
403
  // Reset State
404
  setAssessmentStatus('UPLOADING');
405
  setStreamedAssessment({ transcription: '', feedback: '', score: null, audio: undefined });
 
414
  'x-user-role': currentUser?.role || '',
415
  'x-school-id': currentUser?.schoolId || ''
416
  },
417
+ body: JSON.stringify({ question: assessmentTopic, audio, images })
418
  });
419
 
420
  if (!response.ok) throw new Error(response.statusText);
 
502
  }
503
  };
504
 
505
+ const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
506
+ if (e.target.files && e.target.files.length > 0) {
507
+ setSelectedImages(prev => [...prev, ...Array.from(e.target.files!)]);
508
  }
509
  };
510
 
511
+ const removeImage = (index: number) => {
512
+ setSelectedImages(prev => prev.filter((_, i) => i !== index));
513
+ };
514
+
515
  const confirmImageSubmission = async () => {
516
+ if (selectedImages.length === 0) return;
517
  setAssessmentStatus('UPLOADING');
518
  try {
519
+ const base64Promises = selectedImages.map(file => fileToBase64(file));
520
+ const base64Images = await Promise.all(base64Promises);
521
+ handleAssessmentStreamingSubmit({ images: base64Images });
522
  } catch(e) {
523
  setAssessmentStatus('IDLE');
524
  setToast({ show: true, message: '图片上传失败', type: 'error' });
 
738
  </button>
739
  ) : (
740
  <div className="w-full">
741
+ <div className="relative border-2 border-dashed border-purple-200 rounded-xl p-4 text-center hover:bg-purple-50 transition-colors cursor-pointer min-h-[160px] flex flex-col items-center justify-center">
742
+ <input
743
+ type="file"
744
+ accept="image/*"
745
+ multiple
746
+ className="absolute inset-0 opacity-0 cursor-pointer w-full h-full z-10"
747
+ onChange={handleImageUpload}
748
+ onClick={(e) => (e.currentTarget.value = '')}
749
+ />
750
+ {selectedImages.length === 0 ? (
751
+ <>
752
+ <ImageIcon className="mx-auto text-purple-300 mb-2" size={40}/>
753
+ <p className="text-purple-600 font-bold">点击上传作业图片</p>
754
+ <p className="text-xs text-gray-400">支持批量上传 • 自动批改</p>
755
+ </>
756
+ ) : (
757
+ <div className="z-0 w-full pointer-events-none opacity-50 flex items-center justify-center">
758
+ <Plus className="text-purple-300" size={40}/>
759
+ <span className="text-purple-400 font-bold ml-2">继续添加图片</span>
760
+ </div>
761
+ )}
762
+ </div>
763
+
764
+ {selectedImages.length > 0 && (
765
+ <div className="mt-4 grid grid-cols-3 sm:grid-cols-4 gap-3 animate-in fade-in">
766
+ {selectedImages.map((file, idx) => (
767
+ <div key={idx} className="relative group aspect-square">
768
+ <img src={URL.createObjectURL(file)} className="w-full h-full object-cover rounded-lg shadow-sm border border-gray-200" />
769
+ <button
770
+ onClick={() => setSelectedImages(prev => prev.filter((_, i) => i !== idx))}
771
+ className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 shadow-md hover:bg-red-600 transition-colors z-20 scale-90 hover:scale-100"
772
+ >
773
+ <X size={14}/>
774
+ </button>
775
+ </div>
776
+ ))}
777
  </div>
778
  )}
779
+
780
+ {selectedImages.length > 0 && (
781
+ <button
782
+ onClick={confirmImageSubmission}
783
+ disabled={assessmentStatus !== 'IDLE'}
784
+ className="mt-6 w-full px-8 py-3 bg-purple-600 text-white rounded-lg font-bold hover:bg-purple-700 flex items-center justify-center gap-2 shadow-md transition-all"
785
+ >
786
+ {assessmentStatus !== 'IDLE' ? <Loader2 className="animate-spin" size={18}/> : <CheckCircle size={18}/>}
787
+ {assessmentStatus === 'UPLOADING' ? '上传中...' : assessmentStatus === 'ANALYZING' ? 'AI 正在分析...' : assessmentStatus === 'TTS' ? '生成语音...' : `开始批改 (${selectedImages.length}张)`}
788
+ </button>
789
+ )}
790
  </div>
791
  )}
792
  </div>