dvc890 commited on
Commit
928d114
·
verified ·
1 Parent(s): d54d5e0

Upload 47 files

Browse files
Files changed (8) hide show
  1. components/Sidebar.tsx +8 -2
  2. index.html +2 -1
  3. models.js +4 -1
  4. pages/AIAssistant.tsx +200 -74
  5. pages/UserList.tsx +30 -16
  6. server.js +139 -43
  7. services/api.ts +1 -2
  8. types.ts +5 -2
components/Sidebar.tsx CHANGED
@@ -2,6 +2,7 @@
2
  import React from 'react';
3
  import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building, Gamepad2, CalendarCheck, UserCircle, MessageSquare, Bot } from 'lucide-react';
4
  import { UserRole } from '../types';
 
5
 
6
  interface SidebarProps {
7
  currentView: string;
@@ -13,11 +14,16 @@ interface SidebarProps {
13
  }
14
 
15
  export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, userRole, onLogout, isOpen, onClose }) => {
 
 
 
 
16
  // Define menu items with explicit roles
17
  // PRINCIPAL has access to almost everything ADMIN has, except 'schools' management
18
  const menuItems = [
19
  { id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
20
- { id: 'ai-assistant', label: 'AI 智能助教', icon: Bot, roles: [UserRole.TEACHER, UserRole.STUDENT] }, // NEW
 
21
  { id: 'attendance', label: '考勤管理', icon: CalendarCheck, roles: [UserRole.TEACHER, UserRole.PRINCIPAL] },
22
  { id: 'games', label: '互动教学', icon: Gamepad2, roles: [UserRole.TEACHER, UserRole.STUDENT] }, // Removed PRINCIPAL
23
  { id: 'wishes', label: '心愿与反馈', icon: MessageSquare, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] }, // NEW
@@ -95,4 +101,4 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
95
  </div>
96
  </>
97
  );
98
- };
 
2
  import React from 'react';
3
  import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building, Gamepad2, CalendarCheck, UserCircle, MessageSquare, Bot } from 'lucide-react';
4
  import { UserRole } from '../types';
5
+ import { api } from '../services/api';
6
 
7
  interface SidebarProps {
8
  currentView: string;
 
14
  }
15
 
16
  export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, userRole, onLogout, isOpen, onClose }) => {
17
+ // Get full user object to check aiAccess flag
18
+ const currentUser = api.auth.getCurrentUser();
19
+ const hasAIAccess = userRole === UserRole.ADMIN || (userRole === UserRole.TEACHER && currentUser?.aiAccess);
20
+
21
  // Define menu items with explicit roles
22
  // PRINCIPAL has access to almost everything ADMIN has, except 'schools' management
23
  const menuItems = [
24
  { id: 'dashboard', label: '工作台', icon: LayoutDashboard, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] },
25
+ // AI Assistant: Only if hasAIAccess is true (Admin, or Authorized Teacher)
26
+ { id: 'ai-assistant', label: 'AI 智能助教', icon: Bot, roles: hasAIAccess ? [UserRole.ADMIN, UserRole.TEACHER] : [] },
27
  { id: 'attendance', label: '考勤管理', icon: CalendarCheck, roles: [UserRole.TEACHER, UserRole.PRINCIPAL] },
28
  { id: 'games', label: '互动教学', icon: Gamepad2, roles: [UserRole.TEACHER, UserRole.STUDENT] }, // Removed PRINCIPAL
29
  { id: 'wishes', label: '心愿与反馈', icon: MessageSquare, roles: [UserRole.ADMIN, UserRole.PRINCIPAL, UserRole.TEACHER, UserRole.STUDENT] }, // NEW
 
101
  </div>
102
  </>
103
  );
104
+ };
index.html CHANGED
@@ -31,7 +31,8 @@
31
  "vite": "https://aistudiocdn.com/vite@^7.2.6",
32
  "@vitejs/plugin-react": "https://aistudiocdn.com/@vitejs/plugin-react@^5.1.1",
33
  "react-dom": "https://aistudiocdn.com/react-dom@^19.2.0",
34
- "xlsx": "https://aistudiocdn.com/xlsx@^0.18.5"
 
35
  }
36
  }
37
  </script>
 
31
  "vite": "https://aistudiocdn.com/vite@^7.2.6",
32
  "@vitejs/plugin-react": "https://aistudiocdn.com/@vitejs/plugin-react@^5.1.1",
33
  "react-dom": "https://aistudiocdn.com/react-dom@^19.2.0",
34
+ "xlsx": "https://aistudiocdn.com/xlsx@^0.18.5",
35
+ "@google/genai": "https://esm.sh/@google/genai@^1.33.0"
36
  }
37
  }
38
  </script>
models.js CHANGED
@@ -26,6 +26,8 @@ const UserSchema = new mongoose.Schema({
26
  gender: String,
27
  seatNo: String,
28
  idCard: String,
 
 
29
  classApplication: {
30
  type: { type: String },
31
  targetClass: String,
@@ -34,6 +36,7 @@ const UserSchema = new mongoose.Schema({
34
  });
35
  const User = mongoose.model('User', UserSchema);
36
 
 
37
  const StudentSchema = new mongoose.Schema({
38
  schoolId: String,
39
  studentNo: String,
@@ -225,4 +228,4 @@ module.exports = {
225
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
226
  AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
227
  WishModel, FeedbackModel
228
- };
 
26
  gender: String,
27
  seatNo: String,
28
  idCard: String,
29
+ // NEW: Control AI feature access for teachers
30
+ aiAccess: { type: Boolean, default: false },
31
  classApplication: {
32
  type: { type: String },
33
  targetClass: String,
 
36
  });
37
  const User = mongoose.model('User', UserSchema);
38
 
39
+ // ... (Rest of the file remains unchanged)
40
  const StudentSchema = new mongoose.Schema({
41
  schoolId: String,
42
  studentNo: String,
 
228
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
229
  AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
230
  WishModel, FeedbackModel
231
+ };
pages/AIAssistant.tsx CHANGED
@@ -1,9 +1,8 @@
1
 
2
  import React, { useState, useRef, useEffect } from 'react';
3
  import { api } from '../services/api';
4
- import { AIChatMessage, OralAssessment } from '../types';
5
- import { Bot, Mic, Square, Play, Volume2, Send, CheckCircle, Brain, Sparkles, Loader2, StopCircle } from 'lucide-react';
6
- import { Emoji } from '../components/Emoji';
7
 
8
  // Utility to handle Base64 conversion
9
  const blobToBase64 = (blob: Blob): Promise<string> => {
@@ -18,35 +17,16 @@ const blobToBase64 = (blob: Blob): Promise<string> => {
18
  });
19
  };
20
 
21
- // Utility to decode and play PCM audio (standard Web Audio API)
22
- const playPCMAudio = async (base64Audio: string, audioContext: AudioContext) => {
23
- try {
24
- const binaryString = window.atob(base64Audio);
25
- const len = binaryString.length;
26
- const bytes = new Uint8Array(len);
27
- for (let i = 0; i < len; i++) {
28
- bytes[i] = binaryString.charCodeAt(i);
29
- }
30
-
31
- // Gemini TTS raw output usually needs specific handling.
32
- // If it's pure PCM, we need to wrap it. However, the Gemini API usually returns standard formats if requested or PCM.
33
- // Assuming raw PCM 24kHz mono based on documentation typically seen.
34
- const int16Data = new Int16Array(bytes.buffer);
35
- const float32Data = new Float32Array(int16Data.length);
36
- for (let i = 0; i < int16Data.length; i++) {
37
- float32Data[i] = int16Data[i] / 32768.0;
38
- }
39
-
40
- const buffer = audioContext.createBuffer(1, float32Data.length, 24000); // Gemini Flash TTS default
41
- buffer.getChannelData(0).set(float32Data);
42
-
43
- const source = audioContext.createBufferSource();
44
- source.buffer = buffer;
45
- source.connect(audioContext.destination);
46
- source.start();
47
- } catch (e) {
48
- console.error("Audio playback error:", e);
49
- }
50
  };
51
 
52
  export const AIAssistant: React.FC = () => {
@@ -62,11 +42,14 @@ export const AIAssistant: React.FC = () => {
62
  // Assessment State
63
  const [assessmentTopic, setAssessmentTopic] = useState('请描述你最喜欢的一个季节及其原因。');
64
  const [assessmentResult, setAssessmentResult] = useState<any>(null);
 
 
65
 
66
  // Audio Refs
67
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
68
  const audioChunksRef = useRef<Blob[]>([]);
69
  const audioContextRef = useRef<AudioContext | null>(null);
 
70
 
71
  useEffect(() => {
72
  // Init Audio Context
@@ -79,13 +62,65 @@ export const AIAssistant: React.FC = () => {
79
  setMessages([{
80
  id: 'welcome',
81
  role: 'model',
82
- text: '你好!我是你的 AI 智能助教。你可以问我任何学习上的问题,或者切换到“口语测评”模式来练习口语。',
83
  timestamp: Date.now()
84
  }]);
85
  }
 
 
 
 
86
  }, []);
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  const startRecording = async () => {
 
89
  try {
90
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
91
  const mediaRecorder = new MediaRecorder(stream);
@@ -118,7 +153,7 @@ export const AIAssistant: React.FC = () => {
118
  if (activeTab === 'chat') {
119
  handleChatSubmit(undefined, base64Audio);
120
  } else {
121
- handleAssessmentSubmit(base64Audio);
122
  }
123
 
124
  // Stop all tracks
@@ -129,6 +164,13 @@ export const AIAssistant: React.FC = () => {
129
 
130
  const handleChatSubmit = async (text?: string, audioBase64?: string) => {
131
  if (!text && !audioBase64) return;
 
 
 
 
 
 
 
132
 
133
  // Optimistic UI Update
134
  const newUserMsg: AIChatMessage = {
@@ -143,7 +185,12 @@ export const AIAssistant: React.FC = () => {
143
  setIsProcessing(true);
144
 
145
  try {
146
- const response = await api.ai.chat({ text, audio: audioBase64 });
 
 
 
 
 
147
 
148
  const newAiMsg: AIChatMessage = {
149
  id: (Date.now() + 1).toString(),
@@ -154,50 +201,94 @@ export const AIAssistant: React.FC = () => {
154
  };
155
  setMessages(prev => [...prev, newAiMsg]);
156
 
157
- // Auto play audio if response contains it
158
- if (response.audio && audioContextRef.current) {
159
- playPCMAudio(response.audio, audioContextRef.current);
160
  }
161
 
162
  } catch (error) {
163
  console.error("Chat error:", error);
164
- setMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', text: '抱歉,我遇到了一点问题,请稍后再试。', timestamp: Date.now() }]);
165
  } finally {
166
  setIsProcessing(false);
167
  }
168
  };
169
 
170
- const handleAssessmentSubmit = async (audioBase64: string) => {
 
171
  setIsProcessing(true);
172
  setAssessmentResult(null);
 
173
  try {
174
  const result = await api.ai.evaluate({
175
  question: assessmentTopic,
176
- audio: audioBase64
 
177
  });
178
  setAssessmentResult(result);
 
 
 
 
179
  } catch (error) {
180
  console.error("Eval error:", error);
181
  alert("评分失败,请重试");
182
  } finally {
183
  setIsProcessing(false);
 
 
 
 
 
 
 
 
 
 
 
184
  }
185
  };
186
 
187
- const playMessageAudio = (b64: string) => {
188
- if (audioContextRef.current) playPCMAudio(b64, audioContextRef.current);
 
 
 
 
 
 
 
 
 
 
 
189
  };
190
 
191
  return (
192
- <div className="h-full flex flex-col bg-slate-50 overflow-hidden">
 
 
 
 
 
 
 
 
 
193
  {/* Header Tabs */}
194
- <div className="bg-white border-b border-gray-200 px-6 pt-4 flex gap-6 shrink-0 shadow-sm z-10">
195
- <button onClick={() => setActiveTab('chat')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'chat' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
196
- <Bot size={18} className={activeTab === 'chat' ? 'text-blue-500' : ''}/> AI 助教 (问答)
197
- </button>
198
- <button onClick={() => setActiveTab('assessment')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'assessment' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
199
- <Mic size={18} className={activeTab === 'assessment' ? 'text-purple-500' : ''}/> 口语/背诵测评
200
- </button>
 
 
 
 
 
 
 
201
  </div>
202
 
203
  {/* TAB: CHAT */}
@@ -212,7 +303,7 @@ export const AIAssistant: React.FC = () => {
212
  <div className={`max-w-[80%] p-3 rounded-2xl text-sm ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white border border-gray-200 text-gray-800 rounded-tl-none shadow-sm'}`}>
213
  {msg.text}
214
  {msg.audio && (
215
- <button onClick={() => playMessageAudio(msg.audio!)} className="mt-2 flex items-center gap-2 text-xs bg-blue-50 text-blue-600 px-3 py-1.5 rounded-full hover:bg-blue-100 border border-blue-100 transition-colors w-fit">
216
  <Volume2 size={14}/> 播放语音
217
  </button>
218
  )}
@@ -224,7 +315,7 @@ export const AIAssistant: React.FC = () => {
224
  <div className="w-10 h-10 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center">
225
  <Loader2 className="animate-spin" size={20}/>
226
  </div>
227
- <div className="text-sm text-gray-400 self-center">AI 正在思考中...</div>
228
  </div>
229
  )}
230
  </div>
@@ -270,7 +361,7 @@ export const AIAssistant: React.FC = () => {
270
  </button>
271
  )}
272
  </div>
273
- <p className="text-center text-[10px] text-gray-400 mt-2">Gemini Flash-Lite 模型提供支持</p>
274
  </div>
275
  </div>
276
  )}
@@ -281,8 +372,12 @@ export const AIAssistant: React.FC = () => {
281
  <div className="max-w-3xl mx-auto space-y-6">
282
  {/* Topic Card */}
283
  <div className="bg-white p-6 rounded-2xl border border-purple-100 shadow-sm">
284
- <h3 className="text-lg font-bold text-gray-800 mb-2 flex items-center">
285
- <Brain className="mr-2 text-purple-600"/> 今日测评题目
 
 
 
 
286
  </h3>
287
  <textarea
288
  className="w-full bg-purple-50/50 border border-purple-100 rounded-xl p-4 text-gray-700 font-medium text-lg resize-none focus:ring-2 focus:ring-purple-200 outline-none"
@@ -290,18 +385,43 @@ export const AIAssistant: React.FC = () => {
290
  onChange={e => setAssessmentTopic(e.target.value)}
291
  rows={3}
292
  />
293
- <div className="mt-4 flex justify-center">
294
- <button
295
- onMouseDown={startRecording}
296
- onMouseUp={stopRecording}
297
- onTouchStart={startRecording}
298
- onTouchEnd={stopRecording}
299
- disabled={isProcessing}
300
- className={`px-8 py-4 rounded-full font-bold text-white flex items-center gap-3 shadow-lg transition-all ${isRecording ? 'bg-red-500 scale-105' : 'bg-gradient-to-r from-purple-600 to-indigo-600 hover:shadow-purple-200 hover:scale-105'}`}
301
- >
302
- {isProcessing ? <Loader2 className="animate-spin"/> : (isRecording ? <StopCircle/> : <Mic/>)}
303
- {isProcessing ? 'AI 正在评分...' : (isRecording ? '松开结束录音' : '按住开始回答')}
304
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  </div>
306
  </div>
307
 
@@ -310,20 +430,26 @@ export const AIAssistant: React.FC = () => {
310
  <div className="bg-white p-6 rounded-2xl border border-gray-200 shadow-lg animate-in slide-in-from-bottom-4">
311
  <div className="flex items-center justify-between border-b border-gray-100 pb-4 mb-4">
312
  <h3 className="font-bold text-xl text-gray-800">测评报告</h3>
313
- <div className={`text-3xl font-black ${assessmentResult.score >= 80 ? 'text-green-500' : assessmentResult.score >= 60 ? 'text-yellow-500' : 'text-red-500'}`}>
314
- {assessmentResult.score}<span className="text-sm text-gray-400 ml-1">分</span>
 
 
 
 
 
 
 
315
  </div>
316
  </div>
317
 
318
  <div className="space-y-4">
319
  <div className="bg-gray-50 p-4 rounded-xl">
320
- <p className="text-xs font-bold text-gray-500 uppercase mb-1">AI 听到的内容 (识别文本)</p>
321
- <p className="text-gray-700 leading-relaxed">{assessmentResult.transcription}</p>
322
  </div>
323
  <div>
324
  <p className="text-xs font-bold text-gray-500 uppercase mb-2">AI 点评建议</p>
325
- <div className="p-4 bg-purple-50 text-purple-900 rounded-xl border border-purple-100 text-sm leading-relaxed">
326
- <Emoji symbol="👨‍🏫" className="mr-2"/>
327
  {assessmentResult.feedback}
328
  </div>
329
  </div>
 
1
 
2
  import React, { useState, useRef, useEffect } from 'react';
3
  import { api } from '../services/api';
4
+ import { AIChatMessage } from '../types';
5
+ import { Bot, Mic, Square, Play, Volume2, Send, CheckCircle, Brain, Sparkles, Loader2, StopCircle, Trash2, Image as ImageIcon, X } from 'lucide-react';
 
6
 
7
  // Utility to handle Base64 conversion
8
  const blobToBase64 = (blob: Blob): Promise<string> => {
 
17
  });
18
  };
19
 
20
+ const fileToBase64 = (file: File): Promise<string> => {
21
+ return new Promise((resolve, reject) => {
22
+ const reader = new FileReader();
23
+ reader.readAsDataURL(file);
24
+ reader.onload = () => {
25
+ const str = reader.result as string;
26
+ resolve(str.split(',')[1]);
27
+ };
28
+ reader.onerror = error => reject(error);
29
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  };
31
 
32
  export const AIAssistant: React.FC = () => {
 
42
  // Assessment State
43
  const [assessmentTopic, setAssessmentTopic] = useState('请描述你最喜欢的一个季节及其原因。');
44
  const [assessmentResult, setAssessmentResult] = useState<any>(null);
45
+ const [assessmentMode, setAssessmentMode] = useState<'audio' | 'image'>('audio');
46
+ const [selectedImage, setSelectedImage] = useState<File | null>(null);
47
 
48
  // Audio Refs
49
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
50
  const audioChunksRef = useRef<Blob[]>([]);
51
  const audioContextRef = useRef<AudioContext | null>(null);
52
+ const currentSourceRef = useRef<AudioBufferSourceNode | null>(null);
53
 
54
  useEffect(() => {
55
  // Init Audio Context
 
62
  setMessages([{
63
  id: 'welcome',
64
  role: 'model',
65
+ text: '你好!我是你的 AI 智能助教。你可以问我任何学习上的问题,或者切换到“口语/背诵测评”模式。',
66
  timestamp: Date.now()
67
  }]);
68
  }
69
+
70
+ return () => {
71
+ stopPlayback(); // Cleanup audio on unmount
72
+ };
73
  }, []);
74
 
75
+ const stopPlayback = () => {
76
+ if (currentSourceRef.current) {
77
+ try {
78
+ currentSourceRef.current.stop();
79
+ } catch (e) { /* ignore already stopped */ }
80
+ currentSourceRef.current = null;
81
+ }
82
+ };
83
+
84
+ const playPCMAudio = async (base64Audio: string) => {
85
+ if (!audioContextRef.current) return;
86
+
87
+ stopPlayback(); // Stop any existing audio
88
+
89
+ try {
90
+ const binaryString = window.atob(base64Audio);
91
+ const len = binaryString.length;
92
+ const bytes = new Uint8Array(len);
93
+ for (let i = 0; i < len; i++) {
94
+ bytes[i] = binaryString.charCodeAt(i);
95
+ }
96
+
97
+ // Assuming 24kHz mono PCM (Gemini Standard)
98
+ const int16Data = new Int16Array(bytes.buffer);
99
+ const float32Data = new Float32Array(int16Data.length);
100
+ for (let i = 0; i < int16Data.length; i++) {
101
+ float32Data[i] = int16Data[i] / 32768.0;
102
+ }
103
+
104
+ const buffer = audioContextRef.current.createBuffer(1, float32Data.length, 24000);
105
+ buffer.getChannelData(0).set(float32Data);
106
+
107
+ const source = audioContextRef.current.createBufferSource();
108
+ source.buffer = buffer;
109
+ source.connect(audioContextRef.current.destination);
110
+ source.start();
111
+ currentSourceRef.current = source;
112
+
113
+ source.onended = () => {
114
+ currentSourceRef.current = null;
115
+ };
116
+
117
+ } catch (e) {
118
+ console.error("Audio playback error:", e);
119
+ }
120
+ };
121
+
122
  const startRecording = async () => {
123
+ stopPlayback();
124
  try {
125
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
126
  const mediaRecorder = new MediaRecorder(stream);
 
153
  if (activeTab === 'chat') {
154
  handleChatSubmit(undefined, base64Audio);
155
  } else {
156
+ handleAssessmentSubmit({ audio: base64Audio });
157
  }
158
 
159
  // Stop all tracks
 
164
 
165
  const handleChatSubmit = async (text?: string, audioBase64?: string) => {
166
  if (!text && !audioBase64) return;
167
+ stopPlayback();
168
+
169
+ // 1. Prepare history for context (exclude welcome message usually, or keep it depending on desired persona persistence)
170
+ // Convert to simplified format for API: { role: 'user' | 'model', text: string }
171
+ const historyPayload = messages
172
+ .filter(m => m.id !== 'welcome') // Optional: Exclude greeting to save tokens
173
+ .map(m => ({ role: m.role, text: m.text }));
174
 
175
  // Optimistic UI Update
176
  const newUserMsg: AIChatMessage = {
 
185
  setIsProcessing(true);
186
 
187
  try {
188
+ const response = await api.ai.chat({
189
+ text,
190
+ audio: audioBase64,
191
+ // @ts-ignore
192
+ history: historyPayload
193
+ });
194
 
195
  const newAiMsg: AIChatMessage = {
196
  id: (Date.now() + 1).toString(),
 
201
  };
202
  setMessages(prev => [...prev, newAiMsg]);
203
 
204
+ if (response.audio) {
205
+ playPCMAudio(response.audio);
 
206
  }
207
 
208
  } catch (error) {
209
  console.error("Chat error:", error);
210
+ setMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', text: '抱歉,遇到错误,请重试。', timestamp: Date.now() }]);
211
  } finally {
212
  setIsProcessing(false);
213
  }
214
  };
215
 
216
+ const handleAssessmentSubmit = async ({ audio, image }: { audio?: string, image?: string }) => {
217
+ stopPlayback();
218
  setIsProcessing(true);
219
  setAssessmentResult(null);
220
+
221
  try {
222
  const result = await api.ai.evaluate({
223
  question: assessmentTopic,
224
+ audio,
225
+ image
226
  });
227
  setAssessmentResult(result);
228
+ if (result.audio) {
229
+ // Auto play feedback audio
230
+ playPCMAudio(result.audio);
231
+ }
232
  } catch (error) {
233
  console.error("Eval error:", error);
234
  alert("评分失败,请重试");
235
  } finally {
236
  setIsProcessing(false);
237
+ setSelectedImage(null); // Clear image after submit
238
+ }
239
+ };
240
+
241
+ const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
242
+ if (e.target.files && e.target.files[0]) {
243
+ const file = e.target.files[0];
244
+ setSelectedImage(file);
245
+
246
+ // Auto submit or wait? Let's wait for user confirmation if needed, but for simplicity auto-submit:
247
+ // Actually better to show preview then submit
248
  }
249
  };
250
 
251
+ const confirmImageSubmission = async () => {
252
+ if (!selectedImage) return;
253
+ const b64 = await fileToBase64(selectedImage);
254
+ handleAssessmentSubmit({ image: b64 });
255
+ };
256
+
257
+ const clearHistory = () => {
258
+ setMessages([{
259
+ id: 'welcome',
260
+ role: 'model',
261
+ text: '对话记录已清除��我是你的 AI 智能助教。',
262
+ timestamp: Date.now()
263
+ }]);
264
  };
265
 
266
  return (
267
+ <div className="h-full flex flex-col bg-slate-50 overflow-hidden relative">
268
+ {/* Audio Stop Overlay Button (Visible when audio playing context exists? simplified: always visible top right if needed) */}
269
+ <button
270
+ onClick={stopPlayback}
271
+ className="absolute top-20 right-4 z-50 bg-white/80 backdrop-blur p-2 rounded-full shadow-md text-red-500 hover:bg-white border border-gray-200"
272
+ title="停止播放"
273
+ >
274
+ <StopCircle size={20}/>
275
+ </button>
276
+
277
  {/* Header Tabs */}
278
+ <div className="bg-white border-b border-gray-200 px-6 pt-4 flex justify-between shrink-0 shadow-sm z-10">
279
+ <div className="flex gap-6">
280
+ <button onClick={() => setActiveTab('chat')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'chat' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
281
+ <Bot size={18} className={activeTab === 'chat' ? 'text-blue-500' : ''}/> AI 助教 (问答)
282
+ </button>
283
+ <button onClick={() => setActiveTab('assessment')} className={`pb-3 text-sm font-bold border-b-2 transition-colors flex items-center gap-2 ${activeTab === 'assessment' ? 'border-purple-500 text-purple-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
284
+ <Mic size={18} className={activeTab === 'assessment' ? 'text-purple-500' : ''}/> 口语/背诵测评
285
+ </button>
286
+ </div>
287
+ {activeTab === 'chat' && (
288
+ <button onClick={clearHistory} className="mb-2 text-xs text-gray-400 hover:text-red-500 flex items-center gap-1">
289
+ <Trash2 size={14}/> 清除记录
290
+ </button>
291
+ )}
292
  </div>
293
 
294
  {/* TAB: CHAT */}
 
303
  <div className={`max-w-[80%] p-3 rounded-2xl text-sm ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white border border-gray-200 text-gray-800 rounded-tl-none shadow-sm'}`}>
304
  {msg.text}
305
  {msg.audio && (
306
+ <button onClick={() => playPCMAudio(msg.audio!)} className="mt-2 flex items-center gap-2 text-xs bg-blue-50 text-blue-600 px-3 py-1.5 rounded-full hover:bg-blue-100 border border-blue-100 transition-colors w-fit">
307
  <Volume2 size={14}/> 播放语音
308
  </button>
309
  )}
 
315
  <div className="w-10 h-10 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center">
316
  <Loader2 className="animate-spin" size={20}/>
317
  </div>
318
+ <div className="text-sm text-gray-400 self-center">AI 正在思考上下文...</div>
319
  </div>
320
  )}
321
  </div>
 
361
  </button>
362
  )}
363
  </div>
364
+ <p className="text-center text-[10px] text-gray-400 mt-2">支持多轮对话 | 实时语音合成</p>
365
  </div>
366
  </div>
367
  )}
 
372
  <div className="max-w-3xl mx-auto space-y-6">
373
  {/* Topic Card */}
374
  <div className="bg-white p-6 rounded-2xl border border-purple-100 shadow-sm">
375
+ <h3 className="text-lg font-bold text-gray-800 mb-2 flex items-center justify-between">
376
+ <span className="flex items-center"><Brain className="mr-2 text-purple-600"/> 今日测评题目</span>
377
+ <div className="flex bg-gray-100 p-1 rounded-lg text-xs font-medium">
378
+ <button onClick={()=>setAssessmentMode('audio')} className={`px-3 py-1 rounded-md transition-all ${assessmentMode==='audio'?'bg-white shadow text-purple-600':'text-gray-500'}`}>语音回答</button>
379
+ <button onClick={()=>setAssessmentMode('image')} className={`px-3 py-1 rounded-md transition-all ${assessmentMode==='image'?'bg-white shadow text-purple-600':'text-gray-500'}`}>拍照上传</button>
380
+ </div>
381
  </h3>
382
  <textarea
383
  className="w-full bg-purple-50/50 border border-purple-100 rounded-xl p-4 text-gray-700 font-medium text-lg resize-none focus:ring-2 focus:ring-purple-200 outline-none"
 
385
  onChange={e => setAssessmentTopic(e.target.value)}
386
  rows={3}
387
  />
388
+
389
+ <div className="mt-6 flex justify-center">
390
+ {assessmentMode === 'audio' ? (
391
+ <button
392
+ onMouseDown={startRecording}
393
+ onMouseUp={stopRecording}
394
+ onTouchStart={startRecording}
395
+ onTouchEnd={stopRecording}
396
+ disabled={isProcessing}
397
+ className={`px-8 py-4 rounded-full font-bold text-white flex items-center gap-3 shadow-lg transition-all ${isRecording ? 'bg-red-500 scale-105' : 'bg-gradient-to-r from-purple-600 to-indigo-600 hover:shadow-purple-200 hover:scale-105'}`}
398
+ >
399
+ {isProcessing ? <Loader2 className="animate-spin"/> : (isRecording ? <StopCircle/> : <Mic/>)}
400
+ {isProcessing ? 'AI 正在评分...' : (isRecording ? '松开结束录音' : '按住开始回答')}
401
+ </button>
402
+ ) : (
403
+ <div className="w-full">
404
+ {!selectedImage ? (
405
+ <div className="border-2 border-dashed border-purple-200 rounded-xl p-8 text-center hover:bg-purple-50 transition-colors cursor-pointer relative">
406
+ <input type="file" accept="image/*" className="absolute inset-0 opacity-0 cursor-pointer" onChange={handleImageUpload}/>
407
+ <ImageIcon className="mx-auto text-purple-300 mb-2" size={40}/>
408
+ <p className="text-purple-600 font-bold">点击上传作业图片</p>
409
+ <p className="text-xs text-gray-400">支持手写文字识别与批改</p>
410
+ </div>
411
+ ) : (
412
+ <div className="flex flex-col items-center gap-4">
413
+ <div className="relative">
414
+ <img src={URL.createObjectURL(selectedImage)} className="h-40 rounded-lg shadow-sm border"/>
415
+ <button onClick={()=>setSelectedImage(null)} className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1"><X size={14}/></button>
416
+ </div>
417
+ <button onClick={confirmImageSubmission} disabled={isProcessing} className="px-8 py-2 bg-purple-600 text-white rounded-lg font-bold hover:bg-purple-700 flex items-center gap-2">
418
+ {isProcessing ? <Loader2 className="animate-spin" size={16}/> : <CheckCircle size={16}/>}
419
+ 开始批改
420
+ </button>
421
+ </div>
422
+ )}
423
+ </div>
424
+ )}
425
  </div>
426
  </div>
427
 
 
430
  <div className="bg-white p-6 rounded-2xl border border-gray-200 shadow-lg animate-in slide-in-from-bottom-4">
431
  <div className="flex items-center justify-between border-b border-gray-100 pb-4 mb-4">
432
  <h3 className="font-bold text-xl text-gray-800">测评报告</h3>
433
+ <div className="flex items-center gap-4">
434
+ {assessmentResult.audio && (
435
+ <button onClick={() => playPCMAudio(assessmentResult.audio)} className="flex items-center gap-1 text-sm bg-purple-100 text-purple-700 px-3 py-1 rounded-full hover:bg-purple-200">
436
+ <Volume2 size={16}/> 听点评
437
+ </button>
438
+ )}
439
+ <div className={`text-3xl font-black ${assessmentResult.score >= 80 ? 'text-green-500' : assessmentResult.score >= 60 ? 'text-yellow-500' : 'text-red-500'}`}>
440
+ {assessmentResult.score}<span className="text-sm text-gray-400 ml-1">分</span>
441
+ </div>
442
  </div>
443
  </div>
444
 
445
  <div className="space-y-4">
446
  <div className="bg-gray-50 p-4 rounded-xl">
447
+ <p className="text-xs font-bold text-gray-500 uppercase mb-1">AI 识别内容</p>
448
+ <p className="text-gray-700 leading-relaxed text-sm">{assessmentResult.transcription}</p>
449
  </div>
450
  <div>
451
  <p className="text-xs font-bold text-gray-500 uppercase mb-2">AI 点评建议</p>
452
+ <div className="p-4 bg-purple-50 text-purple-900 rounded-xl border border-purple-100 text-sm leading-relaxed whitespace-pre-wrap">
 
453
  {assessmentResult.feedback}
454
  </div>
455
  </div>
pages/UserList.tsx CHANGED
@@ -2,7 +2,7 @@
2
  import React, { useState, useEffect } from 'react';
3
  import { User, UserRole, UserStatus, School, ClassInfo } from '../types';
4
  import { api } from '../services/api';
5
- import { Loader2, Check, X, Trash2, Edit, Briefcase, GraduationCap, AlertCircle } from 'lucide-react';
6
 
7
  export const UserList: React.FC = () => {
8
  const [users, setUsers] = useState<User[]>([]);
@@ -22,13 +22,9 @@ export const UserList: React.FC = () => {
22
  const loadData = async () => {
23
  setLoading(true);
24
  try {
25
- // Teachers need to see pending students for their class.
26
- // API request:
27
- // - Admin: global=true (gets all) or filtered by header
28
- // - Principal: Standard request (backend uses header to filter by school and exclude admin)
29
  const [u, s, c] = await Promise.all([
30
  api.users.getAll({ global: isAdmin }),
31
- api.schools.getAll(), // Will return empty/public for non-admin but that's fine
32
  api.classes.getAll()
33
  ]);
34
  setUsers(u);
@@ -69,6 +65,14 @@ export const UserList: React.FC = () => {
69
  loadData();
70
  };
71
 
 
 
 
 
 
 
 
 
72
  const submitClassApply = async () => {
73
  if (!applyTarget) return;
74
  try {
@@ -98,26 +102,20 @@ export const UserList: React.FC = () => {
98
 
99
  const getSchoolName = (id?: string) => schools.find(s => s._id === id)?.name || '未分配';
100
 
101
- // Client-side Filtering Logic (Double security besides backend)
102
  const filteredUsers = users.filter(user => {
103
  if (isAdmin) return true;
104
 
105
  if (isPrincipal) {
106
- // Principal cannot see Admins or users from other schools
107
  if (user.role === UserRole.ADMIN) return false;
108
- // Note: user.schoolId might be undefined for some legacy data, handle carefully
109
  if (user.schoolId !== currentUser.schoolId) return false;
110
  return true;
111
  }
112
 
113
  if (isTeacher) {
114
- // Teacher sees ONLY Students
115
  if (user.role !== UserRole.STUDENT) return false;
116
-
117
- // Must match teacher's homeroom
118
  const teacherClass = (currentUser.homeroomClass || '').trim();
119
  const studentClass = (user.homeroomClass || '').trim();
120
-
121
  if (teacherClass && studentClass === teacherClass) {
122
  return true;
123
  }
@@ -126,7 +124,7 @@ export const UserList: React.FC = () => {
126
  return false;
127
  });
128
 
129
- if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-blue-600" /></div>;
130
 
131
  return (
132
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
@@ -171,13 +169,13 @@ export const UserList: React.FC = () => {
171
  <th className="px-4 py-3">所属学校</th>
172
  <th className="px-4 py-3">角色/班级</th>
173
  <th className="px-4 py-3">账号状态</th>
 
174
  <th className="px-4 py-3 text-right">操作</th>
175
  </tr>
176
  </thead>
177
  <tbody className="divide-y divide-gray-100">
178
  {filteredUsers.map(user => {
179
  const isSelf = !!(currentUser && (user._id === currentUser._id || user.username === currentUser.username));
180
-
181
  const hasApp = user.classApplication && user.classApplication.status === 'PENDING';
182
 
183
  return (
@@ -252,6 +250,22 @@ export const UserList: React.FC = () => {
252
  {user.status === 'banned' && <span className="bg-red-100 text-red-700 px-2 py-1 rounded-full text-xs">已停用</span>}
253
  {hasApp && <span className="block mt-1 text-[10px] text-purple-500 font-bold bg-purple-50 px-1 rounded w-fit">申请变动中 (见班级管理)</span>}
254
  </td>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  <td className="px-4 py-3 text-right space-x-2">
256
  {user.status === UserStatus.PENDING && (
257
  <button onClick={() => handleApprove(user)} className="text-green-600 hover:bg-green-50 p-1 rounded border border-green-200" title="通过注册审核">
@@ -294,4 +308,4 @@ export const UserList: React.FC = () => {
294
  )}
295
  </div>
296
  );
297
- };
 
2
  import React, { useState, useEffect } from 'react';
3
  import { User, UserRole, UserStatus, School, ClassInfo } from '../types';
4
  import { api } from '../services/api';
5
+ import { Loader2, Check, X, Trash2, Edit, Briefcase, GraduationCap, AlertCircle, Bot } from 'lucide-react';
6
 
7
  export const UserList: React.FC = () => {
8
  const [users, setUsers] = useState<User[]>([]);
 
22
  const loadData = async () => {
23
  setLoading(true);
24
  try {
 
 
 
 
25
  const [u, s, c] = await Promise.all([
26
  api.users.getAll({ global: isAdmin }),
27
+ api.schools.getAll(),
28
  api.classes.getAll()
29
  ]);
30
  setUsers(u);
 
65
  loadData();
66
  };
67
 
68
+ const handleAIToggle = async (user: User) => {
69
+ const newVal = !user.aiAccess;
70
+ if (confirm(`确认${newVal ? '开通' : '关闭'} ${user.trueName || user.username} 的 AI 助教权限?`)) {
71
+ await api.users.update(user._id || String(user.id), { aiAccess: newVal });
72
+ loadData();
73
+ }
74
+ };
75
+
76
  const submitClassApply = async () => {
77
  if (!applyTarget) return;
78
  try {
 
102
 
103
  const getSchoolName = (id?: string) => schools.find(s => s._id === id)?.name || '未分配';
104
 
105
+ // Client-side Filtering Logic
106
  const filteredUsers = users.filter(user => {
107
  if (isAdmin) return true;
108
 
109
  if (isPrincipal) {
 
110
  if (user.role === UserRole.ADMIN) return false;
 
111
  if (user.schoolId !== currentUser.schoolId) return false;
112
  return true;
113
  }
114
 
115
  if (isTeacher) {
 
116
  if (user.role !== UserRole.STUDENT) return false;
 
 
117
  const teacherClass = (currentUser.homeroomClass || '').trim();
118
  const studentClass = (user.homeroomClass || '').trim();
 
119
  if (teacherClass && studentClass === teacherClass) {
120
  return true;
121
  }
 
124
  return false;
125
  });
126
 
127
+ if (loading) return <div className="flex justify-center py-10"><Loader2 className="animate-spin text-blue-600"/></div>;
128
 
129
  return (
130
  <div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
 
169
  <th className="px-4 py-3">所属学校</th>
170
  <th className="px-4 py-3">角色/班级</th>
171
  <th className="px-4 py-3">账号状态</th>
172
+ {(isAdmin || isPrincipal) && <th className="px-4 py-3 text-center">AI 权限</th>}
173
  <th className="px-4 py-3 text-right">操作</th>
174
  </tr>
175
  </thead>
176
  <tbody className="divide-y divide-gray-100">
177
  {filteredUsers.map(user => {
178
  const isSelf = !!(currentUser && (user._id === currentUser._id || user.username === currentUser.username));
 
179
  const hasApp = user.classApplication && user.classApplication.status === 'PENDING';
180
 
181
  return (
 
250
  {user.status === 'banned' && <span className="bg-red-100 text-red-700 px-2 py-1 rounded-full text-xs">已停用</span>}
251
  {hasApp && <span className="block mt-1 text-[10px] text-purple-500 font-bold bg-purple-50 px-1 rounded w-fit">申请变动中 (见班级管理)</span>}
252
  </td>
253
+
254
+ {/* AI Access Toggle */}
255
+ {(isAdmin || isPrincipal) && (
256
+ <td className="px-4 py-3 text-center">
257
+ {user.role === UserRole.TEACHER ? (
258
+ <button
259
+ onClick={() => handleAIToggle(user)}
260
+ className={`p-1.5 rounded-full transition-colors ${user.aiAccess ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-400 hover:bg-gray-200'}`}
261
+ title={user.aiAccess ? "已开启 AI 助教" : "未开启 AI 助教"}
262
+ >
263
+ <Bot size={16} />
264
+ </button>
265
+ ) : <span className="text-gray-300">-</span>}
266
+ </td>
267
+ )}
268
+
269
  <td className="px-4 py-3 text-right space-x-2">
270
  {user.status === UserStatus.PENDING && (
271
  <button onClick={() => handleApprove(user)} className="text-green-600 hover:bg-green-50 p-1 rounded border border-green-200" title="通过注册审核">
 
308
  )}
309
  </div>
310
  );
311
+ };
server.js CHANGED
@@ -103,35 +103,109 @@ const generateStudentNo = async () => {
103
  return `${year}${random}`;
104
  };
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  // --- NEW AI ROUTES ---
107
 
108
- // Scenario 1: AI Chat (Audio In -> AI Think -> Text + Audio Out)
109
- app.post('/api/ai/chat', async (req, res) => {
110
- const { text, audio } = req.body; // audio is base64 string
 
111
 
112
  try {
113
- // Dynamically import AI
114
  const { ai, Modality } = await getGenAI();
115
 
116
- const parts = [];
 
117
  if (audio) {
118
- parts.push({
119
  inlineData: {
120
- mimeType: 'audio/wav', // Assuming browser sends WAV or compatible
121
  data: audio
122
  }
123
  });
124
  }
125
  if (text) {
126
- parts.push({ text: text });
127
  }
 
 
 
 
 
128
 
129
- if (parts.length === 0) return res.status(400).json({ error: 'No input provided' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
  // Step 1: Thinking (Gemini Flash Lite for low latency)
132
- const thinkingResponse = await ai.models.generateContent({
133
  model: 'gemini-2.5-flash-lite',
134
- contents: { parts: parts },
135
  config: {
136
  systemInstruction: "你是一位友善、耐心且知识渊博的中小学AI助教。请用简洁、鼓励性的语言回答学生的问题。如果学生使用语音,你也应当在回答中体现出自然的口语风格。",
137
  }
@@ -140,14 +214,14 @@ app.post('/api/ai/chat', async (req, res) => {
140
  const answerText = thinkingResponse.text || "抱歉,我没有听清,请再说一遍。";
141
 
142
  // Step 2: Speaking (Gemini TTS)
143
- const ttsResponse = await ai.models.generateContent({
144
  model: "gemini-2.5-flash-preview-tts",
145
  contents: [{ parts: [{ text: answerText }] }],
146
  config: {
147
  responseModalities: [Modality.AUDIO],
148
  speechConfig: {
149
  voiceConfig: {
150
- prebuiltVoiceConfig: { voiceName: 'Kore' }, // 'Puck', 'Charon', 'Kore', 'Fenrir', 'Zephyr'
151
  },
152
  },
153
  },
@@ -157,46 +231,41 @@ app.post('/api/ai/chat', async (req, res) => {
157
 
158
  res.json({
159
  text: answerText,
160
- audio: audioBytes // Base64 PCM data
161
  });
162
 
163
  } catch (e) {
164
  console.error("AI Chat Error:", e);
165
- res.status(500).json({ error: e.message });
166
  }
167
  });
168
 
169
- // Scenario 2: Evaluation (Question + Audio Answer -> AI Score)
170
- app.post('/api/ai/evaluate', async (req, res) => {
171
- const { question, audio } = req.body;
172
 
173
- if (!question || !audio) return res.status(400).json({ error: 'Missing question or audio' });
174
 
175
  try {
176
- // Dynamically import AI
177
- const { ai, Type } = await getGenAI();
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
- // Evaluate using Multimodal capability
180
- const response = await ai.models.generateContent({
181
  model: 'gemini-2.5-flash-lite',
182
- contents: {
183
- parts: [
184
- { text: `请作为一名严谨的口语考官,对学生的回答进行评分。
185
- 题目是:${question}。
186
- 学生的回答在音频中。
187
- 请分析学生的:
188
- 1. 内容准确性 (是否回答了问题)
189
- 2. 语言表达 (流畅度、词汇)
190
- 3. 情感/语调
191
- 请返回 JSON 格式,包含 score (0-100), feedback (简短评语), transcription (你听到的内容)。` },
192
- {
193
- inlineData: {
194
- mimeType: 'audio/wav',
195
- data: audio
196
- }
197
- }
198
- ]
199
- },
200
  config: {
201
  responseMimeType: "application/json",
202
  responseSchema: {
@@ -211,11 +280,38 @@ app.post('/api/ai/evaluate', async (req, res) => {
211
  }
212
  });
213
 
214
- res.json(JSON.parse(response.text));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
  } catch (e) {
217
  console.error("AI Eval Error:", e);
218
- res.status(500).json({ error: e.message });
219
  }
220
  });
221
 
 
103
  return `${year}${random}`;
104
  };
105
 
106
+ // --- Helper: Retry Logic for AI Calls (Fix 503 Errors) ---
107
+ const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
108
+
109
+ async function callAIWithRetry(aiModel, params, retries = 3) {
110
+ for (let i = 0; i < retries; i++) {
111
+ try {
112
+ return await aiModel.generateContent(params);
113
+ } catch (e) {
114
+ // Check for 503 Service Unavailable or "overloaded" message
115
+ const isOverloaded = e.message?.includes('503') ||
116
+ e.status === 503 ||
117
+ e.message?.includes('overloaded');
118
+
119
+ if (isOverloaded) {
120
+ if (i < retries - 1) {
121
+ const delay = 1000 * Math.pow(2, i); // Exponential backoff: 1s, 2s, 4s...
122
+ console.warn(`⚠️ AI Model Overloaded. Retrying in ${delay}ms... (Attempt ${i + 1}/${retries})`);
123
+ await wait(delay);
124
+ continue;
125
+ } else {
126
+ console.error("❌ AI Model Overloaded: Max retries reached.");
127
+ }
128
+ }
129
+ throw e; // Re-throw other errors or if retries exhausted
130
+ }
131
+ }
132
+ }
133
+
134
+ // --- Middleware: Check AI Access ---
135
+ const checkAIAccess = async (req, res, next) => {
136
+ const username = req.headers['x-user-username'];
137
+ if (!username) return res.status(401).json({ error: 'Unauthorized' });
138
+
139
+ // Admins always have access
140
+ if (req.headers['x-user-role'] === 'ADMIN') return next();
141
+
142
+ const user = await User.findOne({ username });
143
+ if (!user) return res.status(404).json({ error: 'User not found' });
144
+
145
+ // Students NO ACCESS
146
+ if (user.role === 'STUDENT') return res.status(403).json({ error: 'Students not allowed' });
147
+
148
+ // Teachers need enabled flag
149
+ if (!user.aiAccess) return res.status(403).json({ error: 'AI Access not enabled for this user' });
150
+
151
+ next();
152
+ };
153
+
154
  // --- NEW AI ROUTES ---
155
 
156
+ // Scenario 1: AI Chat (Audio/Text In -> AI Think -> Text + Audio Out)
157
+ // Now Supports History for Context
158
+ app.post('/api/ai/chat', checkAIAccess, async (req, res) => {
159
+ const { text, audio, history } = req.body;
160
 
161
  try {
 
162
  const { ai, Modality } = await getGenAI();
163
 
164
+ // 1. Build Content parts for Current Turn
165
+ const currentParts = [];
166
  if (audio) {
167
+ currentParts.push({
168
  inlineData: {
169
+ mimeType: 'audio/wav',
170
  data: audio
171
  }
172
  });
173
  }
174
  if (text) {
175
+ currentParts.push({ text: text });
176
  }
177
+ if (currentParts.length === 0) return res.status(400).json({ error: 'No input provided' });
178
+
179
+ // 2. Build Full Context (History + Current)
180
+ // Gemini API `contents` is an array of messages
181
+ const fullContents = [];
182
 
183
+ // Add previous history if exists
184
+ if (history && Array.isArray(history)) {
185
+ history.forEach(msg => {
186
+ // Ensure valid role mapping
187
+ const role = msg.role === 'user' ? 'user' : 'model';
188
+ // Only text history is robustly supported in stateless mode for now to save bandwidth/complexity
189
+ // (Passing back audio bytes in history is heavy)
190
+ if (msg.text) {
191
+ fullContents.push({
192
+ role: role,
193
+ parts: [{ text: msg.text }]
194
+ });
195
+ }
196
+ });
197
+ }
198
+
199
+ // Add current message
200
+ fullContents.push({
201
+ role: 'user',
202
+ parts: currentParts
203
+ });
204
 
205
  // Step 1: Thinking (Gemini Flash Lite for low latency)
206
+ const thinkingResponse = await callAIWithRetry(ai.models, {
207
  model: 'gemini-2.5-flash-lite',
208
+ contents: fullContents, // Send full history
209
  config: {
210
  systemInstruction: "你是一位友善、耐心且知识渊博的中小学AI助教。请用简洁、鼓励性的语言回答学生的问题。如果学生使用语音,你也应当在回答中体现出自然的口语风格。",
211
  }
 
214
  const answerText = thinkingResponse.text || "抱歉,我没有听清,请再说一遍。";
215
 
216
  // Step 2: Speaking (Gemini TTS)
217
+ const ttsResponse = await callAIWithRetry(ai.models, {
218
  model: "gemini-2.5-flash-preview-tts",
219
  contents: [{ parts: [{ text: answerText }] }],
220
  config: {
221
  responseModalities: [Modality.AUDIO],
222
  speechConfig: {
223
  voiceConfig: {
224
+ prebuiltVoiceConfig: { voiceName: 'Kore' },
225
  },
226
  },
227
  },
 
231
 
232
  res.json({
233
  text: answerText,
234
+ audio: audioBytes
235
  });
236
 
237
  } catch (e) {
238
  console.error("AI Chat Error:", e);
239
+ res.status(500).json({ error: e.message || 'AI Service Unavailable' });
240
  }
241
  });
242
 
243
+ // Scenario 2: Evaluation (Question + Audio/Image Answer -> AI Score + Feedback + Audio)
244
+ app.post('/api/ai/evaluate', checkAIAccess, async (req, res) => {
245
+ const { question, audio, image } = req.body; // Image is base64
246
 
247
+ if (!question || (!audio && !image)) return res.status(400).json({ error: 'Missing question or input (audio/image)' });
248
 
249
  try {
250
+ const { ai, Type, Modality } = await getGenAI();
251
+
252
+ const evalParts = [{ text: `请作为一名严谨的老师,对学生的回答进行评分。题目是:${question}。` }];
253
+
254
+ if (audio) {
255
+ evalParts.push({ text: "学生的回答在音频中。" });
256
+ evalParts.push({ inlineData: { mimeType: 'audio/wav', data: audio } });
257
+ }
258
+ if (image) {
259
+ evalParts.push({ text: "学生的回答写在图片中,请识别图片中的文字内容并进行批改。" });
260
+ evalParts.push({ inlineData: { mimeType: 'image/jpeg', data: image } });
261
+ }
262
+
263
+ evalParts.push({ text: `请分析:1. 内容准确性 2. 表达/书写规范。返回 JSON: {score(0-100), feedback(简短评语), transcription(识别内容)}` });
264
 
265
+ // 1. Analyze
266
+ const response = await callAIWithRetry(ai.models, {
267
  model: 'gemini-2.5-flash-lite',
268
+ contents: { parts: evalParts },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  config: {
270
  responseMimeType: "application/json",
271
  responseSchema: {
 
280
  }
281
  });
282
 
283
+ const resultJson = JSON.parse(response.text);
284
+
285
+ // 2. Generate Audio for the Feedback (TTS)
286
+ let feedbackAudio = null;
287
+ if (resultJson.feedback) {
288
+ try {
289
+ const ttsResponse = await callAIWithRetry(ai.models, {
290
+ model: "gemini-2.5-flash-preview-tts",
291
+ contents: [{ parts: [{ text: resultJson.feedback }] }],
292
+ config: {
293
+ responseModalities: [Modality.AUDIO],
294
+ speechConfig: {
295
+ voiceConfig: {
296
+ prebuiltVoiceConfig: { voiceName: 'Kore' },
297
+ },
298
+ },
299
+ },
300
+ });
301
+ feedbackAudio = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
302
+ } catch (ttsErr) {
303
+ console.warn("TTS Generation failed:", ttsErr);
304
+ }
305
+ }
306
+
307
+ res.json({
308
+ ...resultJson,
309
+ audio: feedbackAudio
310
+ });
311
 
312
  } catch (e) {
313
  console.error("AI Eval Error:", e);
314
+ res.status(500).json({ error: e.message || 'AI Service Unavailable' });
315
  }
316
  });
317
 
services/api.ts CHANGED
@@ -1,4 +1,3 @@
1
-
2
  // ... existing imports
3
  import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig, Attendance, LeaveRequest, AchievementConfig, SchoolCalendarEntry, TeacherExchangeConfig, Wish, Feedback, AIChatMessage } from '../types';
4
 
@@ -273,6 +272,6 @@ export const api = {
273
  // NEW: AI Endpoints
274
  ai: {
275
  chat: (data: { text?: string, audio?: string }) => request('/ai/chat', { method: 'POST', body: JSON.stringify(data) }),
276
- evaluate: (data: { question: string, audio: string }) => request('/ai/evaluate', { method: 'POST', body: JSON.stringify(data) }),
277
  }
278
  };
 
 
1
  // ... existing imports
2
  import { User, ClassInfo, SystemConfig, Subject, School, Schedule, GameSession, StudentReward, LuckyDrawConfig, Attendance, LeaveRequest, AchievementConfig, SchoolCalendarEntry, TeacherExchangeConfig, Wish, Feedback, AIChatMessage } from '../types';
3
 
 
272
  // NEW: AI Endpoints
273
  ai: {
274
  chat: (data: { text?: string, audio?: string }) => request('/ai/chat', { method: 'POST', body: JSON.stringify(data) }),
275
+ evaluate: (data: { question: string, audio?: string, image?: string }) => request('/ai/evaluate', { method: 'POST', body: JSON.stringify(data) }),
276
  }
277
  };
types.ts CHANGED
@@ -96,7 +96,9 @@ export interface User {
96
  avatar?: string;
97
  createTime?: string;
98
  teachingSubject?: string;
99
- homeroomClass?: string; // 废弃或仅作显示,主要逻辑移至 ClassInfo.homeroomTeacherIds
 
 
100
  // Class Application
101
  classApplication?: {
102
  type: 'CLAIM' | 'RESIGN';
@@ -114,6 +116,7 @@ export interface User {
114
  }
115
 
116
  export interface ClassInfo {
 
117
  id?: number;
118
  _id?: string;
119
  schoolId?: string;
@@ -415,4 +418,4 @@ export interface SchoolCalendarEntry {
415
  startDate: string; // YYYY-MM-DD
416
  endDate: string; // YYYY-MM-DD
417
  name: string;
418
- }
 
96
  avatar?: string;
97
  createTime?: string;
98
  teachingSubject?: string;
99
+ homeroomClass?: string;
100
+ // NEW: Feature Flag
101
+ aiAccess?: boolean;
102
  // Class Application
103
  classApplication?: {
104
  type: 'CLAIM' | 'RESIGN';
 
116
  }
117
 
118
  export interface ClassInfo {
119
+ // ... (rest remains same)
120
  id?: number;
121
  _id?: string;
122
  schoolId?: string;
 
418
  startDate: string; // YYYY-MM-DD
419
  endDate: string; // YYYY-MM-DD
420
  name: string;
421
+ }