Spaces:
Sleeping
Sleeping
Upload 53 files
Browse files- ai-routes.js +11 -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 [
|
| 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,
|
| 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,
|
| 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 =
|
| 506 |
-
if (e.target.files && e.target.files
|
| 507 |
-
|
| 508 |
}
|
| 509 |
};
|
| 510 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
const confirmImageSubmission = async () => {
|
| 512 |
-
if (
|
| 513 |
setAssessmentStatus('UPLOADING');
|
| 514 |
try {
|
| 515 |
-
const
|
| 516 |
-
|
|
|
|
| 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 |
-
|
| 737 |
-
<
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|