dvc890 commited on
Commit
b785427
·
verified ·
1 Parent(s): 8cafb25

Upload 53 files

Browse files
Files changed (4) hide show
  1. ai-routes.js +253 -209
  2. models.js +5 -1
  3. pages/AIAssistant.tsx +119 -4
  4. types.ts +6 -2
ai-routes.js CHANGED
@@ -2,38 +2,31 @@
2
  const express = require('express');
3
  const router = express.Router();
4
  const OpenAI = require('openai');
5
- // Removed static require causing ERR_REQUIRE_ESM
6
- // const { GoogleGenAI } = require("@google/genai");
7
  const { ConfigModel, User } = require('./models');
8
 
9
- // --- AI Client Initialization ---
10
 
11
- let openAIClient = null;
12
- function getOpenRouter() {
13
- if (openAIClient) return openAIClient;
14
- // 使用环境变量或硬编码的备用 Key (仅供演示,实际应配置在 .env)
15
- const apiKey = process.env.OPENROUTER_API_KEY;
16
- if (!apiKey) return null;
17
 
18
- openAIClient = new OpenAI({
19
- baseURL: "https://openrouter.ai/api/v1",
20
- apiKey: apiKey,
21
- defaultHeaders: {
22
- "HTTP-Referer": "https://smart-school-system.com",
23
- "X-Title": "Smart School System",
24
- },
25
- });
26
- return openAIClient;
27
- }
28
 
29
- // Initialize Gemini Client Dynamically (Lazy Load)
30
- let geminiClientInstance = null;
31
- async function getGeminiClient() {
32
- if (geminiClientInstance) return geminiClientInstance;
33
- // Dynamic import to support ESM package in CJS environment
34
- const { GoogleGenAI } = await import("@google/genai");
35
- geminiClientInstance = new GoogleGenAI({ apiKey: process.env.API_KEY });
36
- return geminiClientInstance;
 
37
  }
38
 
39
  // --- Helpers ---
@@ -94,49 +87,6 @@ function convertGeminiToOpenAI(baseParams) {
94
  return messages;
95
  }
96
 
97
- // Helper to adapt params for Gemma (Remove systemInstruction and prepend to prompt)
98
- function prepareGemmaParams(baseParams, modelName) {
99
- // Deep copy to avoid mutating original for other providers
100
- const newParams = JSON.parse(JSON.stringify(baseParams));
101
- newParams.model = modelName;
102
-
103
- // Gemma models do not support systemInstruction in config, move it to text
104
- if (newParams.config && newParams.config.systemInstruction) {
105
- const sysInst = newParams.config.systemInstruction;
106
- delete newParams.config.systemInstruction;
107
-
108
- const systemPrefix = `[System Instruction: ${sysInst}]\n\n`;
109
-
110
- if (Array.isArray(newParams.contents) && newParams.contents.length > 0) {
111
- // Chat mode: Prepend to first user message
112
- const firstMsg = newParams.contents[0];
113
- if (firstMsg.role === 'user' && firstMsg.parts && firstMsg.parts.length > 0) {
114
- const textPart = firstMsg.parts.find(p => p.text);
115
- if (textPart) {
116
- textPart.text = systemPrefix + textPart.text;
117
- } else {
118
- // Only has image/audio, add text part
119
- firstMsg.parts.unshift({ text: systemPrefix });
120
- }
121
- } else {
122
- // If history starts with model (rare), prepend a new user message
123
- newParams.contents.unshift({ role: 'user', parts: [{ text: systemPrefix }] });
124
- }
125
- } else if (newParams.contents && newParams.contents.parts) {
126
- // Generate content mode (single object)
127
- if (Array.isArray(newParams.contents.parts)) {
128
- const textPart = newParams.contents.parts.find(p => p.text);
129
- if (textPart) {
130
- textPart.text = systemPrefix + textPart.text;
131
- } else {
132
- newParams.contents.parts.unshift({ text: systemPrefix });
133
- }
134
- }
135
- }
136
- }
137
- return newParams;
138
- }
139
-
140
  // --- Dynamic Provider Management ---
141
 
142
  const PROVIDERS = {
@@ -167,83 +117,125 @@ function isQuotaError(e) {
167
  // --- Provider Callers (Generate Content) ---
168
 
169
  async function callGeminiProvider(baseParams) {
170
- const client = await getGeminiClient(); // AWAIT client
171
  const primaryModels = ['gemini-2.5-flash', 'gemini-2.5-flash-lite'];
 
 
 
 
 
172
  let lastError = null;
173
- for (const modelName of primaryModels) {
174
- try {
175
- console.log(`🚀 [AI Debug] Calling Gemini non-stream: ${modelName}`);
176
- const currentParams = { ...baseParams, model: modelName };
177
- return await callAIWithRetry(() => client.models.generateContent(currentParams), 1);
178
- } catch (e) {
179
- lastError = e;
180
- console.error(`⚠️ [AI Debug] Gemini ${modelName} Error:`, e.message);
181
- if (isQuotaError(e)) continue;
182
- throw e;
 
 
 
 
 
 
 
 
 
 
 
183
  }
184
  }
185
- throw lastError;
 
186
  }
187
 
188
  async function callOpenRouterProvider(baseParams) {
189
- const openRouter = getOpenRouter();
190
- if (!openRouter) throw new Error("OpenRouter not configured");
191
-
192
  const openRouterModels = [
193
  'qwen/qwen3-coder:free',
194
  'openai/gpt-oss-120b:free',
195
  'qwen/qwen3-235b-a22b:free',
196
  'tngtech/deepseek-r1t-chimera:free'
197
  ];
198
-
199
  const openAIMessages = convertGeminiToOpenAI(baseParams);
 
 
 
 
200
  let lastError = null;
201
 
202
- for (const modelName of openRouterModels) {
203
- try {
204
- console.log(`🛡️ [AI Debug] Switching to OpenRouter Model: ${modelName}`);
205
- const completion = await openRouter.chat.completions.create({
206
- model: modelName,
207
- messages: openAIMessages,
208
- });
209
- if (!completion || !completion.choices || !completion.choices[0]) {
210
- throw new Error(`Invalid response structure from ${modelName}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  }
212
- const content = completion.choices[0].message.content || "";
213
- // Mock a Gemini-like response object for compatibility
214
- return { text: content }; // Correct property access for text
215
- } catch (e) {
216
- lastError = e;
217
- console.warn(`⚠️ [AI Debug] OpenRouter Model ${modelName} failed.`, e.message);
218
  }
219
  }
220
- throw lastError || new Error("OpenRouter failed");
221
  }
222
 
223
  async function callGemmaProvider(baseParams) {
224
- const client = await getGeminiClient(); // AWAIT client
225
- // Updated to Gemma 3 series as per user quota (including smaller ones)
226
- const fallbackModels = ['gemma-3-27b-it', 'gemma-3-9b-it', 'gemma-3-4b-it'];
 
 
 
 
 
 
227
  let lastError = null;
228
- for (const modelName of fallbackModels) {
229
- try {
230
- console.log(`🛡️ [AI Debug] Switching to Backup (Gemma): ${modelName}`);
231
- // Use adapter to remove systemInstruction
232
- const currentParams = prepareGemmaParams(baseParams, modelName);
233
- return await callAIWithRetry(() => client.models.generateContent(currentParams), 1);
234
- } catch (e) {
235
- lastError = e;
236
- console.warn(`⚠️ [AI Debug] Backup Model ${modelName} failed.`, e.message);
 
 
 
 
 
 
 
237
  }
238
  }
239
- throw lastError || new Error("Gemma failed");
240
  }
241
 
242
  async function generateContentWithSmartFallback(baseParams) {
243
  // If input has audio, force Gemini as others likely don't support multimodal audio directly
244
  let hasAudio = false;
245
  if (baseParams.contents) {
246
- // Handle both simple and complex content structures
247
  const contents = Array.isArray(baseParams.contents) ? baseParams.contents : [baseParams.contents];
248
  contents.forEach(c => {
249
  if (c.parts) c.parts.forEach(p => {
@@ -275,99 +267,128 @@ async function generateContentWithSmartFallback(baseParams) {
275
  // --- Streaming Helpers ---
276
 
277
  async function streamGemini(baseParams, res) {
278
- const client = await getGeminiClient(); // AWAIT client
279
  const models = ['gemini-2.5-flash', 'gemini-2.5-flash-lite'];
 
 
 
280
  let lastError = null;
281
- for (const modelName of models) {
282
- try {
283
- console.log(`🌊 [AI Debug] STREAMING Gemini: ${modelName}`);
284
- const currentParams = { ...baseParams, model: modelName };
285
- const result = await client.models.generateContentStream(currentParams);
286
-
287
- let fullText = "";
288
- // FIX: Iterate result directly, NOT result.stream
289
- for await (const chunk of result) {
290
- // FIX: Access property .text, NOT method .text()
291
- const chunkText = chunk.text;
292
- if (chunkText) {
293
- fullText += chunkText;
294
- res.write(`data: ${JSON.stringify({ text: chunkText })}\n\n`);
295
- if (res.flush) res.flush();
 
 
296
  }
 
 
 
 
 
 
297
  }
298
- return fullText;
299
- } catch (e) {
300
- lastError = e;
301
- console.error(`Streaming GEMINI failed: ${e.message}`);
302
- if (isQuotaError(e)) continue;
303
- throw e;
304
  }
305
  }
306
- throw lastError || new Error("Gemini streaming failed");
307
  }
308
 
309
  async function streamOpenRouter(baseParams, res) {
310
- const openRouter = getOpenRouter();
311
- if (!openRouter) throw new Error("OpenRouter not configured");
312
-
313
  const openRouterModels = ['qwen/qwen3-coder:free', 'openai/gpt-oss-120b:free'];
314
  const messages = convertGeminiToOpenAI(baseParams);
315
 
 
 
 
316
  let lastError = null;
317
- for (const modelName of openRouterModels) {
318
- try {
319
- console.log(`🛡️ [AI Debug] STREAMING OpenRouter: ${modelName}`);
320
- const stream = await openRouter.chat.completions.create({
321
- model: modelName,
322
- messages: messages,
323
- stream: true
324
- });
325
- let fullText = '';
326
- for await (const chunk of stream) {
327
- const text = chunk.choices[0]?.delta?.content || '';
328
- if (text) {
329
- fullText += text;
330
- res.write(`data: ${JSON.stringify({ text: text })}\n\n`);
331
- if (res.flush) res.flush();
 
 
 
 
 
 
 
 
 
 
 
 
332
  }
 
 
 
 
 
333
  }
334
- return fullText;
335
- } catch (e) {
336
- lastError = e;
337
- console.warn(`[AI Debug] Stream OpenRouter ${modelName} failed`, e.message);
338
  }
339
  }
340
- throw lastError || new Error("OpenRouter stream failed");
341
  }
342
 
343
  async function streamGemma(baseParams, res) {
344
- const client = await getGeminiClient(); // AWAIT client
345
- // Updated to Gemma 3 series as per user quota
346
- const models = ['gemma-3-27b-it', 'gemma-3-9b-it', 'gemma-3-4b-it'];
 
 
 
 
 
347
  let lastError = null;
348
- for (const modelName of models) {
349
- try {
350
- console.log(`🛡️ [AI Debug] STREAMING Gemma: ${modelName}`);
351
- // Use adapter to remove systemInstruction
352
- const currentParams = prepareGemmaParams(baseParams, modelName);
353
- const result = await client.models.generateContentStream(currentParams);
354
- let fullText = "";
355
- // FIX: Iterate result directly
356
- for await (const chunk of result) {
357
- const chunkText = chunk.text;
358
- if (chunkText) {
359
- fullText += chunkText;
360
- res.write(`data: ${JSON.stringify({ text: chunkText })}\n\n`);
361
- if (res.flush) res.flush();
 
 
 
 
 
362
  }
 
 
 
 
 
363
  }
364
- return fullText;
365
- } catch (e) {
366
- lastError = e;
367
- console.warn(`Streaming GEMMA ${modelName} failed:`, e.message);
368
  }
369
  }
370
- throw lastError || new Error("Gemma streaming failed");
371
  }
372
 
373
  async function streamContentWithSmartFallback(baseParams, res) {
@@ -471,24 +492,39 @@ router.post('/chat', checkAIAccess, async (req, res) => {
471
  // Note: Other providers generally don't support high-quality TTS via simple API call yet, so we stick to Gemini for TTS or fallback to browser.
472
  if (answerText) {
473
  try {
474
- const client = await getGeminiClient(); // AWAIT client
475
- const ttsResponse = await client.models.generateContent({
476
- model: "gemini-2.5-flash-preview-tts",
477
- contents: [{ parts: [{ text: answerText }] }],
478
- config: {
479
- responseModalities: ['AUDIO'],
480
- speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } },
481
- },
482
- });
483
- const audioBytes = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
484
  if (audioBytes) {
485
  res.write(`data: ${JSON.stringify({ audio: audioBytes })}\n\n`);
486
  } else {
487
- throw new Error("No audio data");
 
488
  }
489
  } catch (ttsError) {
490
  console.warn("⚠️ TTS Generation skipped (Quota or Error):", ttsError.message);
491
- // Send explicit signal to frontend to fallback to browser TTS
492
  res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`);
493
  }
494
  }
@@ -535,7 +571,6 @@ router.post('/evaluate', checkAIAccess, async (req, res) => {
535
  // Handle different return types from fallback
536
  let rawText = "";
537
  if (typeof response.text === 'string') rawText = response.text;
538
- // Check for new SDK structure just in case, though generateContent returns GenerateContentResponse which has .text property
539
  else if (response && response.response && response.response.text) rawText = response.response.text();
540
  else if (typeof response.text === 'function') rawText = response.text();
541
 
@@ -554,20 +589,29 @@ router.post('/evaluate', checkAIAccess, async (req, res) => {
554
  let feedbackAudio = null;
555
  if (resultJson.feedback) {
556
  try {
557
- const client = await getGeminiClient(); // AWAIT client
558
- const ttsResponse = await client.models.generateContent({
559
- model: "gemini-2.5-flash-preview-tts",
560
- contents: [{ parts: [{ text: resultJson.feedback }] }],
561
- config: {
562
- responseModalities: ['AUDIO'],
563
- speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } },
564
- },
565
- });
566
- feedbackAudio = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
 
 
 
 
 
 
 
 
 
 
 
567
  } catch (ttsErr) {
568
  console.warn("⚠️ TTS Generation failed:", ttsErr.message);
569
- // We rely on frontend to use browser TTS if audio is null,
570
- // OR we can signal it in the JSON response if we want explicit control
571
  }
572
  }
573
 
 
2
  const express = require('express');
3
  const router = express.Router();
4
  const OpenAI = require('openai');
 
 
5
  const { ConfigModel, User } = require('./models');
6
 
7
+ // --- Key Management & Rotation ---
8
 
9
+ // Fetch keys from DB + merge with ENV variables
10
+ async function getKeyPool(type) {
11
+ const config = await ConfigModel.findOne({ key: 'main' });
12
+ const pool = [];
 
 
13
 
14
+ // 1. Add DB keys first (Priority)
15
+ if (config && config.apiKeys && config.apiKeys[type] && Array.isArray(config.apiKeys[type])) {
16
+ config.apiKeys[type].forEach(k => {
17
+ if (k && k.trim()) pool.push(k.trim());
18
+ });
19
+ }
 
 
 
 
20
 
21
+ // 2. Add Environment keys as fallback
22
+ if (type === 'gemini' && process.env.API_KEY) {
23
+ if (!pool.includes(process.env.API_KEY)) pool.push(process.env.API_KEY);
24
+ }
25
+ if (type === 'openrouter' && process.env.OPENROUTER_API_KEY) {
26
+ if (!pool.includes(process.env.OPENROUTER_API_KEY)) pool.push(process.env.OPENROUTER_API_KEY);
27
+ }
28
+
29
+ return pool;
30
  }
31
 
32
  // --- Helpers ---
 
87
  return messages;
88
  }
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  // --- Dynamic Provider Management ---
91
 
92
  const PROVIDERS = {
 
117
  // --- Provider Callers (Generate Content) ---
118
 
119
  async function callGeminiProvider(baseParams) {
120
+ const { GoogleGenAI } = await import("@google/genai");
121
  const primaryModels = ['gemini-2.5-flash', 'gemini-2.5-flash-lite'];
122
+
123
+ // Get all available keys
124
+ const keys = await getKeyPool('gemini');
125
+ if (keys.length === 0) throw new Error("No Gemini API keys configured");
126
+
127
  let lastError = null;
128
+
129
+ // KEY ROTATION LOOP
130
+ for (const apiKey of keys) {
131
+ const client = new GoogleGenAI({ apiKey });
132
+
133
+ for (const modelName of primaryModels) {
134
+ try {
135
+ console.log(`🚀 [AI Debug] Calling Gemini non-stream: ${modelName} (Key ends: ${apiKey.slice(-4)})`);
136
+ const currentParams = { ...baseParams, model: modelName };
137
+ return await callAIWithRetry(() => client.models.generateContent(currentParams), 1);
138
+ } catch (e) {
139
+ lastError = e;
140
+ console.error(`⚠️ [AI Debug] Gemini ${modelName} Error with key ${apiKey.slice(-4)}:`, e.message);
141
+
142
+ // If it's a quota error, break the model loop to try next KEY
143
+ if (isQuotaError(e)) {
144
+ break; // Try next key
145
+ }
146
+ // If it's NOT a quota error (e.g. invalid prompt), throw immediately to avoid burning other keys
147
+ throw e;
148
+ }
149
  }
150
  }
151
+ // If we exhausted all keys with quota errors
152
+ throw lastError || new Error("All Gemini keys exhausted");
153
  }
154
 
155
  async function callOpenRouterProvider(baseParams) {
 
 
 
156
  const openRouterModels = [
157
  'qwen/qwen3-coder:free',
158
  'openai/gpt-oss-120b:free',
159
  'qwen/qwen3-235b-a22b:free',
160
  'tngtech/deepseek-r1t-chimera:free'
161
  ];
 
162
  const openAIMessages = convertGeminiToOpenAI(baseParams);
163
+
164
+ const keys = await getKeyPool('openrouter');
165
+ if (keys.length === 0) throw new Error("No OpenRouter API keys configured");
166
+
167
  let lastError = null;
168
 
169
+ for (const apiKey of keys) {
170
+ const client = new OpenAI({
171
+ baseURL: "https://openrouter.ai/api/v1",
172
+ apiKey: apiKey,
173
+ defaultHeaders: {
174
+ "HTTP-Referer": "https://smart-school-system.com",
175
+ "X-Title": "Smart School System",
176
+ },
177
+ });
178
+
179
+ for (const modelName of openRouterModels) {
180
+ try {
181
+ console.log(`🛡️ [AI Debug] Switching to OpenRouter Model: ${modelName} (Key ends: ${apiKey.slice(-4)})`);
182
+ const completion = await client.chat.completions.create({
183
+ model: modelName,
184
+ messages: openAIMessages,
185
+ });
186
+ if (!completion || !completion.choices || !completion.choices[0]) {
187
+ throw new Error(`Invalid response structure from ${modelName}`);
188
+ }
189
+ const content = completion.choices[0].message.content || "";
190
+ return { text: content };
191
+ } catch (e) {
192
+ lastError = e;
193
+ console.warn(`⚠️ [AI Debug] OpenRouter Model ${modelName} failed with key ${apiKey.slice(-4)}.`, e.message);
194
+ if (isQuotaError(e)) break; // Try next key
195
+ // Keep trying models if it's not a quota error? OpenRouter often has specific model downtimes.
196
+ // But for key rotation logic, usually 429 applies to account.
197
  }
 
 
 
 
 
 
198
  }
199
  }
200
+ throw lastError || new Error("OpenRouter failed on all keys");
201
  }
202
 
203
  async function callGemmaProvider(baseParams) {
204
+ // Gemma uses Gemini API Keys
205
+ const { GoogleGenAI } = await import("@google/genai");
206
+ const fallbackModels = ['gemma-3-27b-it', 'gemma-3-12b-it', 'gemma-3-4b-it'];
207
+ const gemmaConfig = { ...baseParams.config };
208
+ if (gemmaConfig.systemInstruction) delete gemmaConfig.systemInstruction;
209
+
210
+ const keys = await getKeyPool('gemini'); // Shared pool
211
+ if (keys.length === 0) throw new Error("No Gemini API keys for Gemma");
212
+
213
  let lastError = null;
214
+ for (const apiKey of keys) {
215
+ const client = new GoogleGenAI({ apiKey });
216
+ for (const modelName of fallbackModels) {
217
+ try {
218
+ console.log(`🛡️ [AI Debug] Switching to Backup (Gemma): ${modelName} (Key ends: ${apiKey.slice(-4)})`);
219
+ const currentParams = {
220
+ ...baseParams,
221
+ model: modelName,
222
+ config: gemmaConfig
223
+ };
224
+ return await callAIWithRetry(() => client.models.generateContent(currentParams), 1);
225
+ } catch (e) {
226
+ lastError = e;
227
+ console.warn(`⚠️ [AI Debug] Backup Model ${modelName} failed with key ${apiKey.slice(-4)}.`, e.message);
228
+ if (isQuotaError(e)) break; // Try next key
229
+ }
230
  }
231
  }
232
+ throw lastError || new Error("Gemma failed on all keys");
233
  }
234
 
235
  async function generateContentWithSmartFallback(baseParams) {
236
  // If input has audio, force Gemini as others likely don't support multimodal audio directly
237
  let hasAudio = false;
238
  if (baseParams.contents) {
 
239
  const contents = Array.isArray(baseParams.contents) ? baseParams.contents : [baseParams.contents];
240
  contents.forEach(c => {
241
  if (c.parts) c.parts.forEach(p => {
 
267
  // --- Streaming Helpers ---
268
 
269
  async function streamGemini(baseParams, res) {
270
+ const { GoogleGenAI } = await import("@google/genai");
271
  const models = ['gemini-2.5-flash', 'gemini-2.5-flash-lite'];
272
+ const keys = await getKeyPool('gemini');
273
+ if (keys.length === 0) throw new Error("No Gemini API keys");
274
+
275
  let lastError = null;
276
+
277
+ for (const apiKey of keys) {
278
+ const client = new GoogleGenAI({ apiKey });
279
+ for (const modelName of models) {
280
+ try {
281
+ console.log(`🌊 [AI Debug] STREAMING Gemini: ${modelName} (Key ends: ${apiKey.slice(-4)})`);
282
+ const currentParams = { ...baseParams, model: modelName };
283
+ const result = await client.models.generateContentStream(currentParams);
284
+
285
+ let fullText = "";
286
+ for await (const chunk of result) {
287
+ const chunkText = chunk.text;
288
+ if (chunkText) {
289
+ fullText += chunkText;
290
+ res.write(`data: ${JSON.stringify({ text: chunkText })}\n\n`);
291
+ if (res.flush) res.flush();
292
+ }
293
  }
294
+ return fullText;
295
+ } catch (e) {
296
+ lastError = e;
297
+ console.error(`Streaming GEMINI failed with key ${apiKey.slice(-4)}: ${e.message}`);
298
+ if (isQuotaError(e)) break; // Try next key
299
+ throw e;
300
  }
 
 
 
 
 
 
301
  }
302
  }
303
+ throw lastError || new Error("Gemini streaming failed on all keys");
304
  }
305
 
306
  async function streamOpenRouter(baseParams, res) {
 
 
 
307
  const openRouterModels = ['qwen/qwen3-coder:free', 'openai/gpt-oss-120b:free'];
308
  const messages = convertGeminiToOpenAI(baseParams);
309
 
310
+ const keys = await getKeyPool('openrouter');
311
+ if (keys.length === 0) throw new Error("No OpenRouter API keys");
312
+
313
  let lastError = null;
314
+
315
+ for (const apiKey of keys) {
316
+ const client = new OpenAI({
317
+ baseURL: "https://openrouter.ai/api/v1",
318
+ apiKey: apiKey,
319
+ defaultHeaders: {
320
+ "HTTP-Referer": "https://smart-school-system.com",
321
+ "X-Title": "Smart School System",
322
+ },
323
+ });
324
+
325
+ for (const modelName of openRouterModels) {
326
+ try {
327
+ console.log(`🛡️ [AI Debug] STREAMING OpenRouter: ${modelName} (Key ends: ${apiKey.slice(-4)})`);
328
+ const stream = await client.chat.completions.create({
329
+ model: modelName,
330
+ messages: messages,
331
+ stream: true
332
+ });
333
+ let fullText = '';
334
+ for await (const chunk of stream) {
335
+ const text = chunk.choices[0]?.delta?.content || '';
336
+ if (text) {
337
+ fullText += text;
338
+ res.write(`data: ${JSON.stringify({ text: text })}\n\n`);
339
+ if (res.flush) res.flush();
340
+ }
341
  }
342
+ return fullText;
343
+ } catch (e) {
344
+ lastError = e;
345
+ console.warn(`[AI Debug] Stream OpenRouter ${modelName} failed with key ${apiKey.slice(-4)}`, e.message);
346
+ if (isQuotaError(e)) break; // Try next key
347
  }
 
 
 
 
348
  }
349
  }
350
+ throw lastError || new Error("OpenRouter stream failed on all keys");
351
  }
352
 
353
  async function streamGemma(baseParams, res) {
354
+ const { GoogleGenAI } = await import("@google/genai");
355
+ const models = ['gemma-3-27b-it', 'gemma-3-12b-it', 'gemma-3-4b-it'];
356
+ const gemmaConfig = { ...baseParams.config };
357
+ if (gemmaConfig.systemInstruction) delete gemmaConfig.systemInstruction;
358
+
359
+ const keys = await getKeyPool('gemini'); // Shared with Gemini
360
+ if (keys.length === 0) throw new Error("No Gemini keys for Gemma");
361
+
362
  let lastError = null;
363
+ for (const apiKey of keys) {
364
+ const client = new GoogleGenAI({ apiKey });
365
+ for (const modelName of models) {
366
+ try {
367
+ console.log(`🛡️ [AI Debug] STREAMING Gemma: ${modelName} (Key ends: ${apiKey.slice(-4)})`);
368
+ const currentParams = {
369
+ ...baseParams,
370
+ model: modelName,
371
+ config: gemmaConfig
372
+ };
373
+ const result = await client.models.generateContentStream(currentParams);
374
+ let fullText = "";
375
+ for await (const chunk of result) {
376
+ const chunkText = chunk.text;
377
+ if (chunkText) {
378
+ fullText += chunkText;
379
+ res.write(`data: ${JSON.stringify({ text: chunkText })}\n\n`);
380
+ if (res.flush) res.flush();
381
+ }
382
  }
383
+ return fullText;
384
+ } catch (e) {
385
+ lastError = e;
386
+ console.warn(`Streaming GEMMA ${modelName} failed with key ${apiKey.slice(-4)}:`, e.message);
387
+ if (isQuotaError(e)) break; // Try next key
388
  }
 
 
 
 
389
  }
390
  }
391
+ throw lastError || new Error("Gemma streaming failed on all keys");
392
  }
393
 
394
  async function streamContentWithSmartFallback(baseParams, res) {
 
492
  // Note: Other providers generally don't support high-quality TTS via simple API call yet, so we stick to Gemini for TTS or fallback to browser.
493
  if (answerText) {
494
  try {
495
+ // For TTS, we also try key rotation
496
+ const { GoogleGenAI } = await import("@google/genai");
497
+ const keys = await getKeyPool('gemini');
498
+ let audioBytes = null;
499
+
500
+ for (const apiKey of keys) {
501
+ try {
502
+ const client = new GoogleGenAI({ apiKey });
503
+ const ttsResponse = await client.models.generateContent({
504
+ model: "gemini-2.5-flash-preview-tts",
505
+ contents: [{ parts: [{ text: answerText }] }],
506
+ config: {
507
+ responseModalities: ['AUDIO'],
508
+ speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } },
509
+ },
510
+ });
511
+ audioBytes = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
512
+ if (audioBytes) break; // Success
513
+ } catch(e) {
514
+ console.warn(`TTS Key ${apiKey.slice(-4)} failed`, e.message);
515
+ if (isQuotaError(e)) continue;
516
+ break; // Non-quota error, probably model unavailable, stop trying
517
+ }
518
+ }
519
+
520
  if (audioBytes) {
521
  res.write(`data: ${JSON.stringify({ audio: audioBytes })}\n\n`);
522
  } else {
523
+ // Signal fallback
524
+ res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`);
525
  }
526
  } catch (ttsError) {
527
  console.warn("⚠️ TTS Generation skipped (Quota or Error):", ttsError.message);
 
528
  res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`);
529
  }
530
  }
 
571
  // Handle different return types from fallback
572
  let rawText = "";
573
  if (typeof response.text === 'string') rawText = response.text;
 
574
  else if (response && response.response && response.response.text) rawText = response.response.text();
575
  else if (typeof response.text === 'function') rawText = response.text();
576
 
 
589
  let feedbackAudio = null;
590
  if (resultJson.feedback) {
591
  try {
592
+ const { GoogleGenAI } = await import("@google/genai");
593
+ const keys = await getKeyPool('gemini');
594
+
595
+ for (const apiKey of keys) {
596
+ try {
597
+ const client = new GoogleGenAI({ apiKey });
598
+ const ttsResponse = await client.models.generateContent({
599
+ model: "gemini-2.5-flash-preview-tts",
600
+ contents: [{ parts: [{ text: resultJson.feedback }] }],
601
+ config: {
602
+ responseModalities: ['AUDIO'],
603
+ speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } },
604
+ },
605
+ });
606
+ feedbackAudio = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
607
+ if (feedbackAudio) break;
608
+ } catch(e) {
609
+ if (isQuotaError(e)) continue;
610
+ break;
611
+ }
612
+ }
613
  } catch (ttsErr) {
614
  console.warn("⚠️ TTS Generation failed:", ttsErr.message);
 
 
615
  }
616
  }
617
 
models.js CHANGED
@@ -116,7 +116,11 @@ const ConfigSchema = new mongoose.Schema({
116
  emailNotify: Boolean,
117
  enableAI: { type: Boolean, default: true },
118
  aiTotalCalls: { type: Number, default: 0 },
119
- periodConfig: [{ period: Number, name: String, startTime: String, endTime: String }] // NEW
 
 
 
 
120
  });
121
  const ConfigModel = mongoose.model('Config', ConfigSchema);
122
 
 
116
  emailNotify: Boolean,
117
  enableAI: { type: Boolean, default: true },
118
  aiTotalCalls: { type: Number, default: 0 },
119
+ periodConfig: [{ period: Number, name: String, startTime: String, endTime: String }],
120
+ apiKeys: {
121
+ gemini: [String],
122
+ openrouter: [String]
123
+ }
124
  });
125
  const ConfigModel = mongoose.model('Config', ConfigSchema);
126
 
pages/AIAssistant.tsx CHANGED
@@ -2,7 +2,7 @@
2
  import React, { useState, useRef, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { AIChatMessage, SystemConfig, UserRole } from '../types';
5
- import { Bot, Mic, Square, Play, Volume2, Send, CheckCircle, Brain, Sparkles, Loader2, StopCircle, Trash2, Image as ImageIcon, X, AlertTriangle, ShieldCheck, Activity, Power, ExternalLink } from 'lucide-react';
6
  import ReactMarkdown from 'react-markdown';
7
  import remarkGfm from 'remark-gfm';
8
  import { Toast, ToastState } from '../components/Toast';
@@ -99,6 +99,12 @@ export const AIAssistant: React.FC = () => {
99
  const [assessmentResult, setAssessmentResult] = useState<{score: number, feedback: string, transcription?: string, audio?: string} | null>(null);
100
  const [selectedImage, setSelectedImage] = useState<File | null>(null);
101
 
 
 
 
 
 
 
102
  // Audio Refs
103
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
104
  const audioChunksRef = useRef<Blob[]>([]);
@@ -116,6 +122,10 @@ export const AIAssistant: React.FC = () => {
116
 
117
  const cfg = await api.config.get();
118
  setSystemConfig(cfg);
 
 
 
 
119
  } catch (e) {
120
  console.error("Init failed", e);
121
  } finally {
@@ -407,12 +417,49 @@ export const AIAssistant: React.FC = () => {
407
  }
408
  };
409
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  if (configLoading) return <div className="flex justify-center items-center h-full"><Loader2 className="animate-spin text-blue-600"/></div>;
411
 
412
  // --- ADMIN VIEW ---
413
  if (isAdmin) {
414
  return (
415
- <div className="p-6 md:p-10 max-w-5xl mx-auto space-y-8 animate-in fade-in">
416
  {toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
417
  <div className="flex items-center gap-4 border-b pb-6">
418
  <div className="p-3 bg-blue-100 rounded-2xl text-blue-600">
@@ -420,7 +467,7 @@ export const AIAssistant: React.FC = () => {
420
  </div>
421
  <div>
422
  <h1 className="text-2xl font-bold text-gray-800">AI 智能助教管理后台</h1>
423
- <p className="text-gray-500">监控 AI 服务状态与用量</p>
424
  </div>
425
  </div>
426
 
@@ -434,7 +481,7 @@ export const AIAssistant: React.FC = () => {
434
  </div>
435
  <div className="text-sm text-gray-500 flex-1">
436
  <p className="mb-2">计费周期: 当前学期</p>
437
- <p>服务商: Google Gemini</p>
438
  </div>
439
  </div>
440
  </div>
@@ -455,6 +502,74 @@ export const AIAssistant: React.FC = () => {
455
  </div>
456
  </div>
457
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  </div>
459
  );
460
  }
 
2
  import React, { useState, useRef, useEffect } from 'react';
3
  import { api } from '../services/api';
4
  import { AIChatMessage, SystemConfig, UserRole } from '../types';
5
+ import { Bot, Mic, Square, Play, Volume2, Send, CheckCircle, Brain, Sparkles, Loader2, StopCircle, Trash2, Image as ImageIcon, X, AlertTriangle, ShieldCheck, Activity, Power, ExternalLink, Key, Plus, Save } from 'lucide-react';
6
  import ReactMarkdown from 'react-markdown';
7
  import remarkGfm from 'remark-gfm';
8
  import { Toast, ToastState } from '../components/Toast';
 
99
  const [assessmentResult, setAssessmentResult] = useState<{score: number, feedback: string, transcription?: string, audio?: string} | null>(null);
100
  const [selectedImage, setSelectedImage] = useState<File | null>(null);
101
 
102
+ // Admin Key Management State
103
+ const [geminiKeys, setGeminiKeys] = useState<string[]>([]);
104
+ const [openRouterKeys, setOpenRouterKeys] = useState<string[]>([]);
105
+ const [newGeminiKey, setNewGeminiKey] = useState('');
106
+ const [newOpenRouterKey, setNewOpenRouterKey] = useState('');
107
+
108
  // Audio Refs
109
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
110
  const audioChunksRef = useRef<Blob[]>([]);
 
122
 
123
  const cfg = await api.config.get();
124
  setSystemConfig(cfg);
125
+ if (isAdmin && cfg.apiKeys) {
126
+ setGeminiKeys(cfg.apiKeys.gemini || []);
127
+ setOpenRouterKeys(cfg.apiKeys.openrouter || []);
128
+ }
129
  } catch (e) {
130
  console.error("Init failed", e);
131
  } finally {
 
417
  }
418
  };
419
 
420
+ const handleAddKey = (type: 'gemini' | 'openrouter') => {
421
+ const key = type === 'gemini' ? newGeminiKey.trim() : newOpenRouterKey.trim();
422
+ if (!key) return;
423
+
424
+ if (type === 'gemini') {
425
+ setGeminiKeys([...geminiKeys, key]);
426
+ setNewGeminiKey('');
427
+ } else {
428
+ setOpenRouterKeys([...openRouterKeys, key]);
429
+ setNewOpenRouterKey('');
430
+ }
431
+ };
432
+
433
+ const removeKey = (type: 'gemini' | 'openrouter', index: number) => {
434
+ if (type === 'gemini') {
435
+ setGeminiKeys(geminiKeys.filter((_, i) => i !== index));
436
+ } else {
437
+ setOpenRouterKeys(openRouterKeys.filter((_, i) => i !== index));
438
+ }
439
+ };
440
+
441
+ const saveApiKeys = async () => {
442
+ if (!systemConfig) return;
443
+ try {
444
+ await api.config.save({
445
+ ...systemConfig,
446
+ apiKeys: {
447
+ gemini: geminiKeys,
448
+ openrouter: openRouterKeys
449
+ }
450
+ });
451
+ setToast({ show: true, message: 'API 密钥配置已保存', type: 'success' });
452
+ } catch (e) {
453
+ setToast({ show: true, message: '保存失败', type: 'error' });
454
+ }
455
+ };
456
+
457
  if (configLoading) return <div className="flex justify-center items-center h-full"><Loader2 className="animate-spin text-blue-600"/></div>;
458
 
459
  // --- ADMIN VIEW ---
460
  if (isAdmin) {
461
  return (
462
+ <div className="p-6 md:p-10 max-w-5xl mx-auto space-y-8 animate-in fade-in pb-20 overflow-y-auto h-full">
463
  {toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
464
  <div className="flex items-center gap-4 border-b pb-6">
465
  <div className="p-3 bg-blue-100 rounded-2xl text-blue-600">
 
467
  </div>
468
  <div>
469
  <h1 className="text-2xl font-bold text-gray-800">AI 智能助教管理后台</h1>
470
+ <p className="text-gray-500">监控 AI 服务状态与用量,管理密钥池。</p>
471
  </div>
472
  </div>
473
 
 
481
  </div>
482
  <div className="text-sm text-gray-500 flex-1">
483
  <p className="mb-2">计费周期: 当前学期</p>
484
+ <p>服务商: Gemini & OpenRouter</p>
485
  </div>
486
  </div>
487
  </div>
 
502
  </div>
503
  </div>
504
  </div>
505
+
506
+ {/* API Key Management */}
507
+ <div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm">
508
+ <div className="flex justify-between items-center mb-6">
509
+ <h3 className="font-bold text-gray-800 flex items-center"><Key className="mr-2 text-amber-500"/> 多线路密钥池配置</h3>
510
+ <button onClick={saveApiKeys} className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2 hover:bg-blue-700 shadow-sm">
511
+ <Save size={16}/> 保存密钥配置
512
+ </button>
513
+ </div>
514
+
515
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
516
+ {/* Gemini Pool */}
517
+ <div>
518
+ <div className="flex items-center justify-between mb-2">
519
+ <label className="text-sm font-bold text-gray-700">Google Gemini / Gemma 密钥池</label>
520
+ <span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">{geminiKeys.length} 个</span>
521
+ </div>
522
+ <p className="text-xs text-gray-400 mb-3">当一个 Key 额度耗尽时,系统将自动切换至下一个。所有 Key 耗尽后尝试 OpenRouter。</p>
523
+ <div className="space-y-2 mb-3">
524
+ {geminiKeys.map((k, idx) => (
525
+ <div key={idx} className="flex gap-2 items-center bg-gray-50 p-2 rounded border border-gray-200">
526
+ <div className="flex-1 font-mono text-xs text-gray-600 truncate">
527
+ {k.substring(0, 8)}...{k.substring(k.length - 6)}
528
+ </div>
529
+ <button onClick={() => removeKey('gemini', idx)} className="text-gray-400 hover:text-red-500"><Trash2 size={14}/></button>
530
+ </div>
531
+ ))}
532
+ </div>
533
+ <div className="flex gap-2">
534
+ <input
535
+ className="flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-blue-500"
536
+ placeholder="输入 Gemini API Key"
537
+ value={newGeminiKey}
538
+ onChange={e => setNewGeminiKey(e.target.value)}
539
+ />
540
+ <button onClick={() => handleAddKey('gemini')} className="bg-gray-100 hover:bg-gray-200 text-gray-600 px-3 py-1.5 rounded border border-gray-300"><Plus size={16}/></button>
541
+ </div>
542
+ </div>
543
+
544
+ {/* OpenRouter Pool */}
545
+ <div>
546
+ <div className="flex items-center justify-between mb-2">
547
+ <label className="text-sm font-bold text-gray-700">OpenRouter 密钥池</label>
548
+ <span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">{openRouterKeys.length} 个</span>
549
+ </div>
550
+ <p className="text-xs text-gray-400 mb-3">作为备用线路,当 Gemini 服务不可用或额度耗尽时启用。</p>
551
+ <div className="space-y-2 mb-3">
552
+ {openRouterKeys.map((k, idx) => (
553
+ <div key={idx} className="flex gap-2 items-center bg-gray-50 p-2 rounded border border-gray-200">
554
+ <div className="flex-1 font-mono text-xs text-gray-600 truncate">
555
+ {k.substring(0, 8)}...{k.substring(k.length - 6)}
556
+ </div>
557
+ <button onClick={() => removeKey('openrouter', idx)} className="text-gray-400 hover:text-red-500"><Trash2 size={14}/></button>
558
+ </div>
559
+ ))}
560
+ </div>
561
+ <div className="flex gap-2">
562
+ <input
563
+ className="flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-purple-500"
564
+ placeholder="输入 OpenRouter API Key"
565
+ value={newOpenRouterKey}
566
+ onChange={e => setNewOpenRouterKey(e.target.value)}
567
+ />
568
+ <button onClick={() => handleAddKey('openrouter')} className="bg-gray-100 hover:bg-gray-200 text-gray-600 px-3 py-1.5 rounded border border-gray-300"><Plus size={16}/></button>
569
+ </div>
570
+ </div>
571
+ </div>
572
+ </div>
573
  </div>
574
  );
575
  }
types.ts CHANGED
@@ -177,7 +177,11 @@ export interface SystemConfig {
177
  emailNotify: boolean;
178
  enableAI?: boolean;
179
  aiTotalCalls?: number;
180
- periodConfig?: PeriodConfig[]; // Kept for backward compatibility or global defaults
 
 
 
 
181
  }
182
 
183
  export interface SchoolCalendarEntry {
@@ -368,4 +372,4 @@ export interface AIChatMessage {
368
  audio?: string;
369
  isAudioMessage?: boolean;
370
  timestamp: number;
371
- }
 
177
  emailNotify: boolean;
178
  enableAI?: boolean;
179
  aiTotalCalls?: number;
180
+ periodConfig?: PeriodConfig[];
181
+ apiKeys?: {
182
+ gemini?: string[];
183
+ openrouter?: string[];
184
+ };
185
  }
186
 
187
  export interface SchoolCalendarEntry {
 
372
  audio?: string;
373
  isAudioMessage?: boolean;
374
  timestamp: number;
375
+ }