dvc890 commited on
Commit
61625f5
·
verified ·
1 Parent(s): 23e52fb

Upload 47 files

Browse files
Files changed (3) hide show
  1. package.json +3 -1
  2. pages/AIAssistant.tsx +3 -0
  3. server.js +219 -43
package.json CHANGED
@@ -1,3 +1,4 @@
 
1
  {
2
  "name": "smart-school-system",
3
  "version": "1.0.0",
@@ -19,6 +20,7 @@
19
  "recharts": "^2.10.3",
20
  "compression": "^1.7.4",
21
  "xlsx": "^0.18.5",
 
22
  "@google/genai": "*"
23
  },
24
  "devDependencies": {
@@ -32,4 +34,4 @@
32
  "typescript": "^5.2.2",
33
  "vite": "^5.0.8"
34
  }
35
- }
 
1
+
2
  {
3
  "name": "smart-school-system",
4
  "version": "1.0.0",
 
20
  "recharts": "^2.10.3",
21
  "compression": "^1.7.4",
22
  "xlsx": "^0.18.5",
23
+ "openai": "^4.28.0",
24
  "@google/genai": "*"
25
  },
26
  "devDependencies": {
 
34
  "typescript": "^5.2.2",
35
  "vite": "^5.0.8"
36
  }
37
+ }
pages/AIAssistant.tsx CHANGED
@@ -177,6 +177,8 @@ export const AIAssistant: React.FC = () => {
177
  console.error("Chat error:", error);
178
  if (error.message.includes('MAINTENANCE')) {
179
  setMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', text: '⚠️ 系统维护中:管理员已暂停 AI 功能。', timestamp: Date.now() }]);
 
 
180
  } else {
181
  setMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', text: '抱歉,遇到错误,请重试。', timestamp: Date.now() }]);
182
  }
@@ -194,6 +196,7 @@ export const AIAssistant: React.FC = () => {
194
  } catch (error: any) {
195
  console.error("Eval error:", error);
196
  if (error.message.includes('MAINTENANCE')) alert("系统维护中,AI 功能暂时不可用");
 
197
  else alert("评分失败,请重试");
198
  } finally { setIsProcessing(false); setSelectedImage(null); }
199
  };
 
177
  console.error("Chat error:", error);
178
  if (error.message.includes('MAINTENANCE')) {
179
  setMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', text: '⚠️ 系统维护中:管理员已暂停 AI 功能。', timestamp: Date.now() }]);
180
+ } else if (error.message === 'QUOTA_EXCEEDED' || error.message.includes('429')) {
181
+ setMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', text: '⚠️ 系统繁忙:AI 请求量已达上限,请休息一会再试。', timestamp: Date.now() }]);
182
  } else {
183
  setMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', text: '抱歉,遇到错误,请重试。', timestamp: Date.now() }]);
184
  }
 
196
  } catch (error: any) {
197
  console.error("Eval error:", error);
198
  if (error.message.includes('MAINTENANCE')) alert("系统维护中,AI 功能暂时不可用");
199
+ else if (error.message === 'QUOTA_EXCEEDED' || error.message.includes('429')) alert("请求过于频繁,请稍后再试");
200
  else alert("评分失败,请重试");
201
  } finally { setIsProcessing(false); setSelectedImage(null); }
202
  };
server.js CHANGED
@@ -6,6 +6,26 @@ const {
6
  WishModel, FeedbackModel
7
  } = require('./models');
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  // Initialize Gemini via Dynamic Import (Helper)
10
  // @google/genai is an ESM-only package, so we cannot use require() in this CommonJS file.
11
  let genAIContext = null;
@@ -103,32 +123,160 @@ const generateStudentNo = async () => {
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 ---
@@ -192,16 +340,12 @@ app.post('/api/ai/chat', checkAIAccess, async (req, res) => {
192
  if (currentParts.length === 0) return res.status(400).json({ error: 'No input provided' });
193
 
194
  // 2. Build Full Context (History + Current)
195
- // Gemini API `contents` is an array of messages
196
  const fullContents = [];
197
 
198
  // Add previous history if exists
199
  if (history && Array.isArray(history)) {
200
  history.forEach(msg => {
201
- // Ensure valid role mapping
202
  const role = msg.role === 'user' ? 'user' : 'model';
203
- // Only text history is robustly supported in stateless mode for now to save bandwidth/complexity
204
- // (Passing back audio bytes in history is heavy)
205
  if (msg.text) {
206
  fullContents.push({
207
  role: role,
@@ -217,10 +361,9 @@ app.post('/api/ai/chat', checkAIAccess, async (req, res) => {
217
  parts: currentParts
218
  });
219
 
220
- // Step 1: Thinking (Gemini Flash Lite for low latency)
221
- const thinkingResponse = await callAIWithRetry(ai.models, {
222
- model: 'gemini-2.5-flash-lite',
223
- contents: fullContents, // Send full history
224
  config: {
225
  systemInstruction: "你是一位友善、耐心且知识渊博的中小学AI助教。请用简洁、鼓励性的语言回答学生的问题。如果学生使用语音,你也应当在回答中体现出自然的口语风格。",
226
  }
@@ -228,21 +371,28 @@ app.post('/api/ai/chat', checkAIAccess, async (req, res) => {
228
 
229
  const answerText = thinkingResponse.text || "抱歉,我没有听清,请再说一遍。";
230
 
231
- // Step 2: Speaking (Gemini TTS)
232
- const ttsResponse = await callAIWithRetry(ai.models, {
233
- model: "gemini-2.5-flash-preview-tts",
234
- contents: [{ parts: [{ text: answerText }] }],
235
- config: {
236
- responseModalities: [Modality.AUDIO],
237
- speechConfig: {
238
- voiceConfig: {
239
- prebuiltVoiceConfig: { voiceName: 'Kore' },
 
 
 
 
 
240
  },
241
  },
242
- },
243
- });
244
-
245
- const audioBytes = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
 
 
246
 
247
  // Increment Counter
248
  await ConfigModel.findOneAndUpdate({ key: 'main' }, { $inc: { aiTotalCalls: 1 } }, { upsert: true });
@@ -254,6 +404,9 @@ app.post('/api/ai/chat', checkAIAccess, async (req, res) => {
254
 
255
  } catch (e) {
256
  console.error("AI Chat Error:", e);
 
 
 
257
  res.status(500).json({ error: e.message || 'AI Service Unavailable' });
258
  }
259
  });
@@ -280,9 +433,11 @@ app.post('/api/ai/evaluate', checkAIAccess, async (req, res) => {
280
 
281
  evalParts.push({ text: `请分析:1. 内容准确性 2. 表达/书写规范。返回 JSON: {score(0-100), feedback(简短评语), transcription(识别内容)}` });
282
 
283
- // 1. Analyze
284
- const response = await callAIWithRetry(ai.models, {
285
- model: 'gemini-2.5-flash-lite',
 
 
286
  contents: { parts: evalParts },
287
  config: {
288
  responseMimeType: "application/json",
@@ -298,13 +453,31 @@ app.post('/api/ai/evaluate', checkAIAccess, async (req, res) => {
298
  }
299
  });
300
 
301
- const resultJson = JSON.parse(response.text);
 
 
 
 
 
 
 
302
 
303
- // 2. Generate Audio for the Feedback (TTS)
 
 
 
 
 
 
 
 
 
 
 
304
  let feedbackAudio = null;
305
  if (resultJson.feedback) {
306
  try {
307
- const ttsResponse = await callAIWithRetry(ai.models, {
308
  model: "gemini-2.5-flash-preview-tts",
309
  contents: [{ parts: [{ text: resultJson.feedback }] }],
310
  config: {
@@ -318,7 +491,7 @@ app.post('/api/ai/evaluate', checkAIAccess, async (req, res) => {
318
  });
319
  feedbackAudio = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
320
  } catch (ttsErr) {
321
- console.warn("TTS Generation failed:", ttsErr);
322
  }
323
  }
324
 
@@ -332,6 +505,9 @@ app.post('/api/ai/evaluate', checkAIAccess, async (req, res) => {
332
 
333
  } catch (e) {
334
  console.error("AI Eval Error:", e);
 
 
 
335
  res.status(500).json({ error: e.message || 'AI Service Unavailable' });
336
  }
337
  });
 
6
  WishModel, FeedbackModel
7
  } = require('./models');
8
 
9
+ // Initialize OpenAI (OpenRouter) Client
10
+ // We use lazy initialization inside the function to avoid crashes if API Key is missing initially
11
+ const OpenAI = require('openai');
12
+ let openAIClient = null;
13
+
14
+ function getOpenRouter() {
15
+ if (openAIClient) return openAIClient;
16
+ if (!process.env.OPENROUTER_API_KEY) return null;
17
+
18
+ openAIClient = new OpenAI({
19
+ baseURL: "https://openrouter.ai/api/v1",
20
+ apiKey: process.env.OPENROUTER_API_KEY,
21
+ defaultHeaders: {
22
+ "HTTP-Referer": "https://smart-school-ai.com", // Placeholder
23
+ "X-Title": "Smart School System",
24
+ },
25
+ });
26
+ return openAIClient;
27
+ }
28
+
29
  // Initialize Gemini via Dynamic Import (Helper)
30
  // @google/genai is an ESM-only package, so we cannot use require() in this CommonJS file.
31
  let genAIContext = null;
 
123
  return `${year}${random}`;
124
  };
125
 
126
+ // --- Helper: Basic Retry Logic (Network blips) ---
127
  const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
128
 
129
+ async function callAIWithRetry(aiModel, params, retries = 1) {
130
  for (let i = 0; i < retries; i++) {
131
  try {
132
  return await aiModel.generateContent(params);
133
  } catch (e) {
134
+ // If it's a critical auth error or bad request, don't retry locally
135
+ if (e.status === 400 || e.status === 401 || e.status === 403) throw e;
136
+ if (i < retries - 1) {
137
+ await wait(1000 * Math.pow(2, i));
138
+ continue;
139
+ }
140
+ throw e;
141
+ }
142
+ }
143
+ }
144
+
145
+ // --- Adapter: Google Gemini Params -> OpenAI Messages ---
146
+ function convertGeminiToOpenAI(baseParams) {
147
+ const messages = [];
148
+
149
+ // 1. System Instruction
150
+ if (baseParams.config?.systemInstruction) {
151
+ messages.push({ role: 'system', content: baseParams.config.systemInstruction });
152
+ }
153
+
154
+ // 2. Chat History & User Input
155
+ if (baseParams.contents && Array.isArray(baseParams.contents)) {
156
+ baseParams.contents.forEach(content => {
157
+ let role = 'user';
158
+ if (content.role === 'model') role = 'assistant';
159
+
160
+ // Extract text from parts
161
+ let textParts = [];
162
+ if (content.parts && Array.isArray(content.parts)) {
163
+ content.parts.forEach(p => {
164
+ if (p.text) textParts.push(p.text);
165
+ // OpenRouter free models usually don't support image parts well via standard OpenAI format,
166
+ // so we largely ignore non-text parts here or add a placeholder.
167
+ });
168
+ }
169
+
170
+ if (textParts.length > 0) {
171
+ messages.push({ role: role, content: textParts.join('\n') });
172
+ }
173
+ });
174
+ }
175
+
176
+ return messages;
177
+ }
178
+
179
+ // --- NEW Helper: Smart Model Fallback Strategy (Multi-Tier) ---
180
+ async function generateContentWithSmartFallback(aiModelObj, baseParams) {
181
+ // Check if request has media (image/audio)
182
+ const hasMedia = baseParams.contents && Array.isArray(baseParams.contents) &&
183
+ baseParams.contents.some(c => c.parts && c.parts.some(p => p.inlineData));
184
+
185
+ // --- Tier 1: Google Gemini (Best quality, Multi-modal) ---
186
+ const primaryModels = [
187
+ 'gemini-2.5-flash', // Standard (20 RPD) - Try first
188
+ 'gemini-2.5-flash-lite' // Lite (20 RPD) - Separate quota bucket
189
+ ];
190
+
191
+ for (const modelName of primaryModels) {
192
+ try {
193
+ const currentParams = { ...baseParams, model: modelName };
194
+ return await callAIWithRetry(aiModelObj, currentParams, 1);
195
+ } catch (e) {
196
+ const isQuotaOrServerIssue = e.status === 429 || e.status === 503 ||
197
+ e.message?.includes('Quota') ||
198
+ e.message?.includes('overloaded') ||
199
+ e.message?.includes('RESOURCE_EXHAUSTED');
200
 
201
+ if (isQuotaOrServerIssue) {
202
+ console.warn(`⚠️ Gemini Model ${modelName} exhausted/busy. Trying next...`);
203
+ continue;
204
+ } else {
205
+ throw e; // Non-quota errors should fail immediately
206
+ }
207
+ }
208
+ }
209
+
210
+ // --- Tier 2: OpenRouter (Text Only, Free Models) ---
211
+ // Only attempt if NO media, as free OR models are mostly text-based or tricky with images via standard API
212
+ const openRouter = getOpenRouter();
213
+
214
+ if (!hasMedia && openRouter) {
215
+ const openRouterModels = [
216
+ 'qwen/qwen3-coder:free',
217
+ 'openai/gpt-oss-120b:free', // Supports reasoning
218
+ 'qwen/qwen3-235b-a22b:free',
219
+ 'tngtech/deepseek-r1t-chimera:free'
220
+ ];
221
+
222
+ const openAIMessages = convertGeminiToOpenAI(baseParams);
223
+
224
+ for (const modelName of openRouterModels) {
225
+ try {
226
+ console.log(`🛡️ Switching to OpenRouter Model: ${modelName}`);
227
+
228
+ // Specific config for reasoning models
229
+ const extraBody = {};
230
+ if (modelName === 'openai/gpt-oss-120b:free') {
231
+ // extraBody.reasoning = { enabled: true }; // Optional: Enable if supported by library/endpoint
232
  }
233
+
234
+ const completion = await openRouter.chat.completions.create({
235
+ model: modelName,
236
+ messages: openAIMessages,
237
+ // ...extraBody
238
+ });
239
+
240
+ // Normalize response to look like Gemini's { text: "..." }
241
+ const content = completion.choices[0]?.message?.content || "";
242
+ return { text: content };
243
+
244
+ } catch (e) {
245
+ console.warn(`⚠️ OpenRouter Model ${modelName} failed.`, e.message);
246
+ // Continue to next OpenRouter model
247
+ }
248
+ }
249
+ }
250
+
251
+ // --- Tier 3: Google Gemma 3 (Final Resort, Text Only) ---
252
+ // Note: 'gemma-3-27b-it' consumes the 14,400 RPD quota.
253
+ const fallbackModels = [
254
+ 'gemma-3-27b-it',
255
+ 'gemma-3-12b-it',
256
+ 'gemma-3-4b-it'
257
+ ];
258
+
259
+ if (!hasMedia) {
260
+ // Strip system instruction for Gemma compatibility
261
+ const gemmaConfig = { ...baseParams.config };
262
+ if (gemmaConfig.systemInstruction) delete gemmaConfig.systemInstruction;
263
+
264
+ for (const modelName of fallbackModels) {
265
+ try {
266
+ console.log(`🛡️ Switching to Final Backup (Gemma 3): ${modelName}`);
267
+ const currentParams = {
268
+ ...baseParams,
269
+ model: modelName,
270
+ config: gemmaConfig
271
+ };
272
+ return await callAIWithRetry(aiModelObj, currentParams, 1);
273
+ } catch (e) {
274
+ console.warn(`⚠️ Backup Model ${modelName} failed.`, e.message);
275
  }
 
276
  }
277
  }
278
+
279
+ throw new Error('All AI models (Gemini, OpenRouter, Gemma) are currently unavailable.');
280
  }
281
 
282
  // --- Middleware: Check AI Access ---
 
340
  if (currentParts.length === 0) return res.status(400).json({ error: 'No input provided' });
341
 
342
  // 2. Build Full Context (History + Current)
 
343
  const fullContents = [];
344
 
345
  // Add previous history if exists
346
  if (history && Array.isArray(history)) {
347
  history.forEach(msg => {
 
348
  const role = msg.role === 'user' ? 'user' : 'model';
 
 
349
  if (msg.text) {
350
  fullContents.push({
351
  role: role,
 
361
  parts: currentParts
362
  });
363
 
364
+ // Step 1: Thinking (Using Multi-Stage Smart Fallback)
365
+ const thinkingResponse = await generateContentWithSmartFallback(ai.models, {
366
+ contents: fullContents,
 
367
  config: {
368
  systemInstruction: "你是一位友善、耐心且知识渊博的中小学AI助教。请用简洁、鼓励性的语言回答学生的问题。如果学生使用语音,你也应当在回答中体现出自然的口语风格。",
369
  }
 
371
 
372
  const answerText = thinkingResponse.text || "抱歉,我没有听清,请再说一遍。";
373
 
374
+ // Step 2: Speaking (Gemini TTS)
375
+ // CRITICAL FIX: Wrap TTS in try-catch to allow graceful degradation.
376
+ // If TTS fails (quota exceeded), we simply return text without audio.
377
+ let audioBytes = null;
378
+ try {
379
+ const ttsResponse = await ai.models.generateContent({
380
+ model: "gemini-2.5-flash-preview-tts", // No fallback for TTS currently as it's specialized
381
+ contents: [{ parts: [{ text: answerText }] }],
382
+ config: {
383
+ responseModalities: [Modality.AUDIO],
384
+ speechConfig: {
385
+ voiceConfig: {
386
+ prebuiltVoiceConfig: { voiceName: 'Kore' },
387
+ },
388
  },
389
  },
390
+ });
391
+ audioBytes = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
392
+ } catch (ttsError) {
393
+ // Log but do not fail the request
394
+ console.warn("⚠️ TTS Generation skipped (Quota or Error). Returning text only.");
395
+ }
396
 
397
  // Increment Counter
398
  await ConfigModel.findOneAndUpdate({ key: 'main' }, { $inc: { aiTotalCalls: 1 } }, { upsert: true });
 
404
 
405
  } catch (e) {
406
  console.error("AI Chat Error:", e);
407
+ if (e.status === 429 || e.message?.includes('QUOTA_EXCEEDED') || e.message?.includes('RESOURCE_EXHAUSTED')) {
408
+ return res.status(429).json({ error: 'QUOTA_EXCEEDED', message: '所有AI模型(包括备用线路)的免费额度均已耗尽,请明天再试。' });
409
+ }
410
  res.status(500).json({ error: e.message || 'AI Service Unavailable' });
411
  }
412
  });
 
433
 
434
  evalParts.push({ text: `请分析:1. 内容准确性 2. 表达/书写规范。返回 JSON: {score(0-100), feedback(简短评语), transcription(识别内容)}` });
435
 
436
+ // 1. Analyze (Using Multi-Stage Smart Fallback)
437
+ // Note: Evaluation usually outputs JSON. Gemma might struggle with strict JSON schemas without system instruction.
438
+ // We hope Primary Tier handles this. If falling back to Gemma, it might return raw text.
439
+ // For stability, Evaluation heavily relies on Gemini.
440
+ const response = await generateContentWithSmartFallback(ai.models, {
441
  contents: { parts: evalParts },
442
  config: {
443
  responseMimeType: "application/json",
 
453
  }
454
  });
455
 
456
+ let resultJson;
457
+ // Attempt to parse JSON. OpenRouter models might wrap JSON in Markdown code blocks (```json ... ```).
458
+ let rawText = response.text || "{}";
459
+ if (rawText.includes('```json')) {
460
+ rawText = rawText.replace(/```json/g, '').replace(/```/g, '').trim();
461
+ } else if (rawText.includes('```')) {
462
+ rawText = rawText.replace(/```/g, '').trim();
463
+ }
464
 
465
+ try {
466
+ resultJson = JSON.parse(rawText);
467
+ } catch (jsonErr) {
468
+ // Fallback for models that return unstructured text
469
+ resultJson = {
470
+ score: 0,
471
+ feedback: rawText,
472
+ transcription: "(解析 JSON 失败,显示原始回复)"
473
+ };
474
+ }
475
+
476
+ // 2. Generate Audio for the Feedback (TTS) - Graceful Degradation
477
  let feedbackAudio = null;
478
  if (resultJson.feedback) {
479
  try {
480
+ const ttsResponse = await ai.models.generateContent({
481
  model: "gemini-2.5-flash-preview-tts",
482
  contents: [{ parts: [{ text: resultJson.feedback }] }],
483
  config: {
 
491
  });
492
  feedbackAudio = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
493
  } catch (ttsErr) {
494
+ console.warn("⚠️ TTS Generation failed:", ttsErr.message);
495
  }
496
  }
497
 
 
505
 
506
  } catch (e) {
507
  console.error("AI Eval Error:", e);
508
+ if (e.status === 429 || e.message?.includes('QUOTA_EXCEEDED') || e.message?.includes('RESOURCE_EXHAUSTED')) {
509
+ return res.status(429).json({ error: 'QUOTA_EXCEEDED', message: '所有AI模型的免费额度均已耗尽,请明天再试。' });
510
+ }
511
  res.status(500).json({ error: e.message || 'AI Service Unavailable' });
512
  }
513
  });