dvc890 commited on
Commit
e1c03e9
·
verified ·
1 Parent(s): 37ea041

Upload 63 files

Browse files
Files changed (4) hide show
  1. ai-routes.js +172 -23
  2. components/ai/AdminPanel.tsx +36 -19
  3. models.js +4 -2
  4. types.ts +3 -2
ai-routes.js CHANGED
@@ -2,6 +2,7 @@
2
  const express = require('express');
3
  const router = express.Router();
4
  const OpenAI = require('openai');
 
5
  const { ConfigModel, User, AIUsageModel, ChatHistoryModel } = require('./models');
6
  const { buildUserContext } = require('./ai-context');
7
 
@@ -71,8 +72,9 @@ function convertGeminiToOpenAI(baseParams) {
71
  return messages;
72
  }
73
 
74
- const PROVIDERS = { GEMINI: 'GEMINI', OPENROUTER: 'OPENROUTER', GEMMA: 'GEMMA' };
75
  const DEFAULT_OPENROUTER_MODELS = ['qwen/qwen3-coder:free', 'openai/gpt-oss-120b:free', 'qwen/qwen3-235b-a22b:free', 'tngtech/deepseek-r1t-chimera:free'];
 
76
 
77
  // Runtime override logic
78
  let runtimeProviderOrder = [];
@@ -86,7 +88,8 @@ function deprioritizeProvider(providerName) {
86
 
87
  function isQuotaError(e) {
88
  const msg = (e.message || '').toLowerCase();
89
- return e.status === 429 || e.status === 503 || msg.includes('quota') || msg.includes('overloaded') || msg.includes('resource_exhausted') || msg.includes('rate limit') || msg.includes('credits');
 
90
  }
91
 
92
  // Streaming Helpers
@@ -132,6 +135,160 @@ async function streamGemini(baseParams, res) {
132
  throw new Error("Gemini streaming failed (All keys/models exhausted)");
133
  }
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  async function streamOpenRouter(baseParams, res) {
136
  const config = await ConfigModel.findOne({ key: 'main' });
137
  const models = (config && config.openRouterModels?.length) ? config.openRouterModels.map(m => m.id) : DEFAULT_OPENROUTER_MODELS;
@@ -151,16 +308,12 @@ async function streamOpenRouter(baseParams, res) {
151
 
152
  const client = new OpenAI({ baseURL, apiKey, defaultHeaders: { "HTTP-Referer": "https://smart.com", "X-Title": "Smart School" } });
153
 
154
- // --- DOUBAO OPTIMIZATION (Context Caching) ---
155
  const extraBody = {};
 
156
  if (modelName.toLowerCase().includes('doubao')) {
157
- console.log(`[AI] 💡 Activating Doubao Prefix Caching for ${modelName}`);
158
- // Doubao-specific caching parameter
159
  extraBody.caching = { type: "enabled", prefix: true };
160
- // Disable thinking to save tokens/time if not needed (optional based on user pref, but here we prioritize speed for chat)
161
  extraBody.thinking = { type: "disabled" };
162
  }
163
- // ---------------------------------------------
164
 
165
  try {
166
  console.log(`[AI] 🚀 Attempting ${providerLabel} Model: ${modelName} (URL: ${baseURL})`);
@@ -234,7 +387,7 @@ async function streamGemma(baseParams, res) {
234
  throw new Error("Gemma stream failed");
235
  }
236
 
237
- async function streamContentWithSmartFallback(baseParams, res) {
238
  let hasAudio = false;
239
  const contentsArray = Array.isArray(baseParams.contents) ? baseParams.contents : [baseParams.contents];
240
 
@@ -258,7 +411,7 @@ async function streamContentWithSmartFallback(baseParams, res) {
258
  const config = await ConfigModel.findOne({ key: 'main' });
259
  const configuredOrder = config?.aiProviderOrder && config.aiProviderOrder.length > 0
260
  ? config.aiProviderOrder
261
- : [PROVIDERS.GEMINI, PROVIDERS.OPENROUTER, PROVIDERS.GEMMA];
262
 
263
  const runtimeSet = new Set(runtimeProviderOrder);
264
  if (runtimeProviderOrder.length === 0 || runtimeProviderOrder.length !== configuredOrder.length || !configuredOrder.every(p => runtimeSet.has(p))) {
@@ -268,9 +421,10 @@ async function streamContentWithSmartFallback(baseParams, res) {
268
  let finalError = null;
269
  for (const provider of runtimeProviderOrder) {
270
  try {
271
- console.log(`[AI] 👉 Trying Provider: ${provider}...`);
272
  if (provider === PROVIDERS.GEMINI) return await streamGemini(baseParams, res);
273
  else if (provider === PROVIDERS.OPENROUTER) return await streamOpenRouter(baseParams, res);
 
274
  else if (provider === PROVIDERS.GEMMA) return await streamGemma(baseParams, res);
275
  } catch (e) {
276
  console.error(`[AI] ❌ Provider ${provider} Failed: ${e.message}`);
@@ -368,20 +522,9 @@ router.post('/chat', checkAIAccess, async (req, res) => {
368
  }));
369
 
370
  // 3. PREPARE REQUEST
371
- // The last user message is already in DB and retrieved in historyContext.
372
- // We need to separate "history" from "current message" for some APIs,
373
- // but Google/OpenAI handle a list of messages fine.
374
- // However, standard pattern is: History + Current.
375
- // Since we fetched ALL (including current), we just pass historyContext as contents.
376
- // NOTE: If audio is present, we must append it specifically as the "current" part
377
- // because DB only stores text representation for now.
378
-
379
  const fullContents = [...historyContext];
380
 
381
- // If this request has audio, append it as a new part (since DB load only has text placeholder)
382
- // We replace the last 'user' text message with the audio payload for the AI model
383
  if (audio) {
384
- // Remove the text placeholder we just loaded
385
  if (fullContents.length > 0 && fullContents[fullContents.length - 1].role === 'user') {
386
  fullContents.pop();
387
  }
@@ -397,10 +540,11 @@ router.post('/chat', checkAIAccess, async (req, res) => {
397
  const combinedSystemInstruction = `${baseSystemInstruction}\n${contextPrompt}`;
398
  // ---------------------------
399
 
 
400
  const answerText = await streamContentWithSmartFallback({
401
  contents: fullContents,
402
  config: { systemInstruction: combinedSystemInstruction }
403
- }, res);
404
 
405
  // 4. SAVE AI RESPONSE TO DB
406
  if (answerText) {
@@ -438,6 +582,11 @@ router.post('/chat', checkAIAccess, async (req, res) => {
438
  // STREAMING ASSESSMENT ENDPOINT
439
  router.post('/evaluate', checkAIAccess, async (req, res) => {
440
  const { question, audio, image, images } = req.body;
 
 
 
 
 
441
  res.setHeader('Content-Type', 'text/event-stream');
442
  res.setHeader('Cache-Control', 'no-cache');
443
  res.setHeader('Connection', 'keep-alive');
@@ -482,7 +631,7 @@ router.post('/evaluate', checkAIAccess, async (req, res) => {
482
  // CRITICAL FIX: Pass as array of objects for OpenRouter compatibility
483
  contents: [{ role: 'user', parts: evalParts }],
484
  // NO JSON MODE to allow progressive text streaming
485
- }, res);
486
 
487
  // Extract Feedback for TTS
488
  const feedbackMatch = fullText.match(/## Feedback\s+([\s\S]*?)(?=## Score|$)/i);
 
2
  const express = require('express');
3
  const router = express.Router();
4
  const OpenAI = require('openai');
5
+ const axios = require('axios'); // Imported Axios for Doubao
6
  const { ConfigModel, User, AIUsageModel, ChatHistoryModel } = require('./models');
7
  const { buildUserContext } = require('./ai-context');
8
 
 
72
  return messages;
73
  }
74
 
75
+ const PROVIDERS = { GEMINI: 'GEMINI', OPENROUTER: 'OPENROUTER', GEMMA: 'GEMMA', DOUBAO: 'DOUBAO' };
76
  const DEFAULT_OPENROUTER_MODELS = ['qwen/qwen3-coder:free', 'openai/gpt-oss-120b:free', 'qwen/qwen3-235b-a22b:free', 'tngtech/deepseek-r1t-chimera:free'];
77
+ const DEFAULT_DOUBAO_MODEL = 'doubao-seed-1-6-251015';
78
 
79
  // Runtime override logic
80
  let runtimeProviderOrder = [];
 
88
 
89
  function isQuotaError(e) {
90
  const msg = (e.message || '').toLowerCase();
91
+ const responseData = e.response?.data ? JSON.stringify(e.response.data).toLowerCase() : '';
92
+ return e.status === 429 || e.status === 503 || msg.includes('quota') || msg.includes('overloaded') || msg.includes('resource_exhausted') || msg.includes('rate limit') || msg.includes('credits') || responseData.includes('insufficient_balance');
93
  }
94
 
95
  // Streaming Helpers
 
135
  throw new Error("Gemini streaming failed (All keys/models exhausted)");
136
  }
137
 
138
+ // --- DOUBAO CONTEXT & STREAMING ---
139
+
140
+ // Helper: Create/Reuse Context
141
+ async function getDoubaoContextId(apiKey, model, systemMessage, userId) {
142
+ // 1. Check User DB for existing ID
143
+ const user = await User.findById(userId);
144
+ if (user && user.doubaoContextId) {
145
+ console.log(`[AI] 🔄 Using existing Doubao Context ID: ${user.doubaoContextId}`);
146
+ return user.doubaoContextId;
147
+ }
148
+
149
+ // 2. Create New Context
150
+ try {
151
+ console.log(`[AI] 🆕 Creating new Doubao Context for User: ${userId}`);
152
+ const response = await axios.post(
153
+ 'https://ark.cn-beijing.volces.com/api/v3/context',
154
+ {
155
+ model: model,
156
+ messages: [systemMessage], // Initialize context with System Prompt
157
+ ttl: 3600 * 24 * 7 // Keep context for 7 days
158
+ },
159
+ { headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` } }
160
+ );
161
+
162
+ const newContextId = response.data.context_id || response.data.id;
163
+
164
+ if (newContextId) {
165
+ // Save to User
166
+ await User.findByIdAndUpdate(userId, { doubaoContextId: newContextId });
167
+ return newContextId;
168
+ }
169
+ } catch (e) {
170
+ console.error('Doubao Context Creation Failed:', e.response?.data || e.message);
171
+ // Fallback: Return null, chat will proceed without context_id (stateless)
172
+ }
173
+ return null;
174
+ }
175
+
176
+ async function streamDoubao(baseParams, res, userId, mode = 'chat') {
177
+ const keys = await getKeyPool('doubao');
178
+ if (keys.length === 0) throw new Error("No Doubao API keys configured");
179
+
180
+ // Convert to OpenAI format
181
+ const messages = convertGeminiToOpenAI(baseParams);
182
+ if (messages.length === 0) throw new Error("Doubao: Empty messages");
183
+
184
+ const model = DEFAULT_DOUBAO_MODEL;
185
+ const apiUrl = 'https://ark.cn-beijing.volces.com/api/v3/chat/completions';
186
+
187
+ // Extract System Prompt for Context Creation if needed
188
+ const systemMsgObj = messages.find(m => m.role === 'system') || { role: 'system', content: 'You are a helpful assistant.' };
189
+
190
+ for (const apiKey of keys) {
191
+ try {
192
+ let requestBody = {
193
+ model: model,
194
+ stream: true,
195
+ messages: messages
196
+ };
197
+
198
+ // --- MODE SPECIFIC CONFIG ---
199
+ if (mode === 'evaluate') {
200
+ // Assessment Mode: Enable Thinking, Disable Context
201
+ console.log(`[AI] 🧠 Doubao Evaluate Mode: Thinking ON, Caching OFF`);
202
+ requestBody.thinking = { type: "enabled" }; // Enable reasoning
203
+ // Note: explicit caching params not sent implies no explicit context ID usage
204
+ } else {
205
+ // Chat Mode: Disable Thinking, Enable Context Caching
206
+ console.log(`[AI] 💬 Doubao Chat Mode: Thinking OFF, Context Caching ON`);
207
+
208
+ requestBody.thinking = { type: "disabled" };
209
+
210
+ // Try to get/create Context ID
211
+ if (userId) {
212
+ const contextId = await getDoubaoContextId(apiKey, model, systemMsgObj, userId);
213
+ if (contextId) {
214
+ requestBody.context_id = contextId;
215
+ // Important: When using context_id, Ark might expect only NEW messages?
216
+ // Ark docs vary, but usually standard is send full history or append.
217
+ // If strict context, we might strip system prompt from 'messages' as it's in context.
218
+ // For safety/compatibility with standard OpenAI adapters, we keep messages.
219
+ }
220
+ }
221
+ }
222
+
223
+ console.log(`[AI] 🚀 Requesting Doubao...`);
224
+
225
+ const response = await axios.post(apiUrl, requestBody, {
226
+ headers: {
227
+ 'Content-Type': 'application/json',
228
+ 'Authorization': `Bearer ${apiKey}`
229
+ },
230
+ responseType: 'stream'
231
+ });
232
+
233
+ console.log(`[AI] ✅ Connected to Doubao`);
234
+ recordUsage(model, PROVIDERS.DOUBAO);
235
+
236
+ let fullText = "";
237
+ let buffer = "";
238
+
239
+ return new Promise((resolve, reject) => {
240
+ response.data.on('data', chunk => {
241
+ buffer += chunk.toString();
242
+ const lines = buffer.split('\n');
243
+ buffer = lines.pop();
244
+
245
+ for (const line of lines) {
246
+ const trimmed = line.trim();
247
+ if (trimmed.startsWith('data: ')) {
248
+ const data = trimmed.slice(6);
249
+ if (data === '[DONE]') continue;
250
+ try {
251
+ const parsed = JSON.parse(data);
252
+ // Handle Thinking Content (Reasoning)
253
+ const reasoning = parsed.choices[0]?.delta?.reasoning_content;
254
+ const content = parsed.choices[0]?.delta?.content || '';
255
+
256
+ // We filter out reasoning content for the final user output in Chat,
257
+ // but for Evaluate we might want to keep it or just use the final content.
258
+ // Current frontend expects 'text'.
259
+
260
+ if (content) {
261
+ fullText += content;
262
+ res.write(`data: ${JSON.stringify({ text: content })}\n\n`);
263
+ if (res.flush) res.flush();
264
+ }
265
+ } catch (e) {}
266
+ }
267
+ }
268
+ });
269
+
270
+ response.data.on('end', () => {
271
+ resolve(fullText);
272
+ });
273
+
274
+ response.data.on('error', (err) => {
275
+ console.error('Doubao Stream Error', err);
276
+ reject(err);
277
+ });
278
+ });
279
+
280
+ } catch (e) {
281
+ console.warn(`[AI] ⚠️ Doubao Error: ${e.message}`, e.response?.data);
282
+ if (isQuotaError(e)) {
283
+ console.log(`[AI] 🔄 Quota exceeded, trying next key...`);
284
+ continue;
285
+ }
286
+ throw e;
287
+ }
288
+ }
289
+ throw new Error("Doubao streaming failed (All keys exhausted)");
290
+ }
291
+
292
  async function streamOpenRouter(baseParams, res) {
293
  const config = await ConfigModel.findOne({ key: 'main' });
294
  const models = (config && config.openRouterModels?.length) ? config.openRouterModels.map(m => m.id) : DEFAULT_OPENROUTER_MODELS;
 
308
 
309
  const client = new OpenAI({ baseURL, apiKey, defaultHeaders: { "HTTP-Referer": "https://smart.com", "X-Title": "Smart School" } });
310
 
 
311
  const extraBody = {};
312
+ // If user uses Doubao via OpenRouter/Custom Proxy, also try to apply cache/thinking params
313
  if (modelName.toLowerCase().includes('doubao')) {
 
 
314
  extraBody.caching = { type: "enabled", prefix: true };
 
315
  extraBody.thinking = { type: "disabled" };
316
  }
 
317
 
318
  try {
319
  console.log(`[AI] 🚀 Attempting ${providerLabel} Model: ${modelName} (URL: ${baseURL})`);
 
387
  throw new Error("Gemma stream failed");
388
  }
389
 
390
+ async function streamContentWithSmartFallback(baseParams, res, userId, mode = 'chat') {
391
  let hasAudio = false;
392
  const contentsArray = Array.isArray(baseParams.contents) ? baseParams.contents : [baseParams.contents];
393
 
 
411
  const config = await ConfigModel.findOne({ key: 'main' });
412
  const configuredOrder = config?.aiProviderOrder && config.aiProviderOrder.length > 0
413
  ? config.aiProviderOrder
414
+ : [PROVIDERS.GEMINI, PROVIDERS.OPENROUTER, PROVIDERS.DOUBAO, PROVIDERS.GEMMA];
415
 
416
  const runtimeSet = new Set(runtimeProviderOrder);
417
  if (runtimeProviderOrder.length === 0 || runtimeProviderOrder.length !== configuredOrder.length || !configuredOrder.every(p => runtimeSet.has(p))) {
 
421
  let finalError = null;
422
  for (const provider of runtimeProviderOrder) {
423
  try {
424
+ console.log(`[AI] 👉 Trying Provider: ${provider}... Mode: ${mode}`);
425
  if (provider === PROVIDERS.GEMINI) return await streamGemini(baseParams, res);
426
  else if (provider === PROVIDERS.OPENROUTER) return await streamOpenRouter(baseParams, res);
427
+ else if (provider === PROVIDERS.DOUBAO) return await streamDoubao(baseParams, res, userId, mode);
428
  else if (provider === PROVIDERS.GEMMA) return await streamGemma(baseParams, res);
429
  } catch (e) {
430
  console.error(`[AI] ❌ Provider ${provider} Failed: ${e.message}`);
 
522
  }));
523
 
524
  // 3. PREPARE REQUEST
 
 
 
 
 
 
 
 
525
  const fullContents = [...historyContext];
526
 
 
 
527
  if (audio) {
 
528
  if (fullContents.length > 0 && fullContents[fullContents.length - 1].role === 'user') {
529
  fullContents.pop();
530
  }
 
540
  const combinedSystemInstruction = `${baseSystemInstruction}\n${contextPrompt}`;
541
  // ---------------------------
542
 
543
+ // Pass userId for Doubao Context creation
544
  const answerText = await streamContentWithSmartFallback({
545
  contents: fullContents,
546
  config: { systemInstruction: combinedSystemInstruction }
547
+ }, res, user._id, 'chat');
548
 
549
  // 4. SAVE AI RESPONSE TO DB
550
  if (answerText) {
 
582
  // STREAMING ASSESSMENT ENDPOINT
583
  router.post('/evaluate', checkAIAccess, async (req, res) => {
584
  const { question, audio, image, images } = req.body;
585
+
586
+ // Extract User info for userId passing
587
+ const username = req.headers['x-user-username'];
588
+ const user = await User.findOne({ username });
589
+
590
  res.setHeader('Content-Type', 'text/event-stream');
591
  res.setHeader('Cache-Control', 'no-cache');
592
  res.setHeader('Connection', 'keep-alive');
 
631
  // CRITICAL FIX: Pass as array of objects for OpenRouter compatibility
632
  contents: [{ role: 'user', parts: evalParts }],
633
  // NO JSON MODE to allow progressive text streaming
634
+ }, res, user?._id, 'evaluate'); // Pass mode='evaluate'
635
 
636
  // Extract Feedback for TTS
637
  const feedbackMatch = fullText.match(/## Feedback\s+([\s\S]*?)(?=## Score|$)/i);
components/ai/AdminPanel.tsx CHANGED
@@ -30,8 +30,11 @@ export const AdminPanel: React.FC = () => {
30
  // Key Management
31
  const [geminiKeys, setGeminiKeys] = useState<string[]>([]);
32
  const [openRouterKeys, setOpenRouterKeys] = useState<string[]>([]);
 
 
33
  const [newGeminiKey, setNewGeminiKey] = useState('');
34
  const [newOpenRouterKey, setNewOpenRouterKey] = useState('');
 
35
 
36
  // Model Management
37
  const [orModels, setOrModels] = useState<OpenRouterModelConfig[]>([]);
@@ -40,7 +43,7 @@ export const AdminPanel: React.FC = () => {
40
  const [newModelApiUrl, setNewModelApiUrl] = useState('');
41
 
42
  // Provider Priority
43
- const [providerOrder, setProviderOrder] = useState<string[]>(['GEMINI', 'OPENROUTER', 'GEMMA']);
44
 
45
  useEffect(() => {
46
  loadData();
@@ -53,6 +56,7 @@ export const AdminPanel: React.FC = () => {
53
  if (cfg.apiKeys) {
54
  setGeminiKeys(cfg.apiKeys.gemini || []);
55
  setOpenRouterKeys(cfg.apiKeys.openrouter || []);
 
56
  }
57
  setOrModels(cfg.openRouterModels && cfg.openRouterModels.length > 0 ? cfg.openRouterModels : DEFAULT_OR_MODELS);
58
 
@@ -79,16 +83,20 @@ export const AdminPanel: React.FC = () => {
79
  }
80
  };
81
 
82
- const handleAddKey = (type: 'gemini' | 'openrouter') => {
83
- const key = type === 'gemini' ? newGeminiKey.trim() : newOpenRouterKey.trim();
 
84
  if (!key) return;
 
85
  if (type === 'gemini') { setGeminiKeys([...geminiKeys, key]); setNewGeminiKey(''); }
86
- else { setOpenRouterKeys([...openRouterKeys, key]); setNewOpenRouterKey(''); }
 
87
  };
88
 
89
- const removeKey = (type: 'gemini' | 'openrouter', index: number) => {
90
  if (type === 'gemini') setGeminiKeys(geminiKeys.filter((_, i) => i !== index));
91
- else setOpenRouterKeys(openRouterKeys.filter((_, i) => i !== index));
 
92
  };
93
 
94
  const handleAddModel = () => {
@@ -122,7 +130,7 @@ export const AdminPanel: React.FC = () => {
122
  try {
123
  await api.config.save({
124
  ...systemConfig,
125
- apiKeys: { gemini: geminiKeys, openrouter: openRouterKeys },
126
  openRouterModels: orModels,
127
  aiProviderOrder: providerOrder
128
  });
@@ -187,18 +195,27 @@ export const AdminPanel: React.FC = () => {
187
  </div>
188
  <div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm">
189
  <div className="flex justify-between items-center mb-6"><h3 className="font-bold text-gray-800 flex items-center"><Key className="mr-2 text-amber-500"/> 多线路密钥池配置</h3><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"><Save size={16}/> 保存所有配置</button></div>
190
- <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
191
- <div>
192
- <div className="flex items-center justify-between mb-2"><label className="text-sm font-bold text-gray-700">Google Gemini / Gemma 密钥池</label><span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">{geminiKeys.length} 个</span></div>
193
- <p className="text-xs text-gray-400 mb-3">当一个 Key 额度耗尽时,系统将自动切换至下一个。</p>
194
- <div className="space-y-2 mb-3">{geminiKeys.map((k, idx) => (<div key={idx} className="flex gap-2 items-center bg-gray-50 p-2 rounded border border-gray-200"><div className="flex-1 font-mono text-xs text-gray-600 truncate">{k.substring(0, 8)}...{k.substring(k.length - 6)}</div><button onClick={() => removeKey('gemini', idx)} className="text-gray-400 hover:text-red-500"><Trash2 size={14}/></button></div>))}</div>
195
- <div className="flex gap-2"><input className="flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-blue-500" placeholder="输入 Gemini API Key" value={newGeminiKey} onChange={e => setNewGeminiKey(e.target.value)}/><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></div>
 
 
 
 
 
 
 
 
196
  </div>
197
- <div>
198
- <div className="flex items-center justify-between mb-2"><label className="text-sm font-bold text-gray-700">OpenRouter (通用) 密钥池</label><span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">{openRouterKeys.length} 个</span></div>
199
- <p className="text-xs text-gray-400 mb-3">备用线路。所有下方“大模型列表”中的模型都将使用这里的 Key。</p>
200
- <div className="space-y-2 mb-3">{openRouterKeys.map((k, idx) => (<div key={idx} className="flex gap-2 items-center bg-gray-50 p-2 rounded border border-gray-200"><div className="flex-1 font-mono text-xs text-gray-600 truncate">{k.substring(0, 8)}...{k.substring(k.length - 6)}</div><button onClick={() => removeKey('openrouter', idx)} className="text-gray-400 hover:text-red-500"><Trash2 size={14}/></button></div>))}</div>
201
- <div className="flex gap-2"><input className="flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-purple-500" placeholder="输入 API Key" value={newOpenRouterKey} onChange={e => setNewOpenRouterKey(e.target.value)}/><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></div>
 
202
  </div>
203
  </div>
204
 
@@ -235,4 +252,4 @@ export const AdminPanel: React.FC = () => {
235
  </div>
236
  </div>
237
  );
238
- };
 
30
  // Key Management
31
  const [geminiKeys, setGeminiKeys] = useState<string[]>([]);
32
  const [openRouterKeys, setOpenRouterKeys] = useState<string[]>([]);
33
+ const [doubaoKeys, setDoubaoKeys] = useState<string[]>([]); // New Doubao State
34
+
35
  const [newGeminiKey, setNewGeminiKey] = useState('');
36
  const [newOpenRouterKey, setNewOpenRouterKey] = useState('');
37
+ const [newDoubaoKey, setNewDoubaoKey] = useState(''); // New Doubao Input
38
 
39
  // Model Management
40
  const [orModels, setOrModels] = useState<OpenRouterModelConfig[]>([]);
 
43
  const [newModelApiUrl, setNewModelApiUrl] = useState('');
44
 
45
  // Provider Priority
46
+ const [providerOrder, setProviderOrder] = useState<string[]>(['GEMINI', 'OPENROUTER', 'DOUBAO', 'GEMMA']);
47
 
48
  useEffect(() => {
49
  loadData();
 
56
  if (cfg.apiKeys) {
57
  setGeminiKeys(cfg.apiKeys.gemini || []);
58
  setOpenRouterKeys(cfg.apiKeys.openrouter || []);
59
+ setDoubaoKeys(cfg.apiKeys.doubao || []); // Load Doubao keys
60
  }
61
  setOrModels(cfg.openRouterModels && cfg.openRouterModels.length > 0 ? cfg.openRouterModels : DEFAULT_OR_MODELS);
62
 
 
83
  }
84
  };
85
 
86
+ const handleAddKey = (type: 'gemini' | 'openrouter' | 'doubao') => {
87
+ const keyMap = { gemini: newGeminiKey, openrouter: newOpenRouterKey, doubao: newDoubaoKey };
88
+ const key = keyMap[type].trim();
89
  if (!key) return;
90
+
91
  if (type === 'gemini') { setGeminiKeys([...geminiKeys, key]); setNewGeminiKey(''); }
92
+ else if (type === 'openrouter') { setOpenRouterKeys([...openRouterKeys, key]); setNewOpenRouterKey(''); }
93
+ else if (type === 'doubao') { setDoubaoKeys([...doubaoKeys, key]); setNewDoubaoKey(''); }
94
  };
95
 
96
+ const removeKey = (type: 'gemini' | 'openrouter' | 'doubao', index: number) => {
97
  if (type === 'gemini') setGeminiKeys(geminiKeys.filter((_, i) => i !== index));
98
+ else if (type === 'openrouter') setOpenRouterKeys(openRouterKeys.filter((_, i) => i !== index));
99
+ else if (type === 'doubao') setDoubaoKeys(doubaoKeys.filter((_, i) => i !== index));
100
  };
101
 
102
  const handleAddModel = () => {
 
130
  try {
131
  await api.config.save({
132
  ...systemConfig,
133
+ apiKeys: { gemini: geminiKeys, openrouter: openRouterKeys, doubao: doubaoKeys },
134
  openRouterModels: orModels,
135
  aiProviderOrder: providerOrder
136
  });
 
195
  </div>
196
  <div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm">
197
  <div className="flex justify-between items-center mb-6"><h3 className="font-bold text-gray-800 flex items-center"><Key className="mr-2 text-amber-500"/> 多线路密钥池配置</h3><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"><Save size={16}/> 保存所有配置</button></div>
198
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
199
+ {/* Gemini Keys */}
200
+ <div className="bg-gray-50/50 p-3 rounded-xl border border-gray-100">
201
+ <div className="flex items-center justify-between mb-2"><label className="text-sm font-bold text-gray-700">Google Gemini / Gemma</label><span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">{geminiKeys.length} 个</span></div>
202
+ <p className="text-[10px] text-gray-400 mb-3">当额度耗尽时自动切换。</p>
203
+ <div className="space-y-2 mb-3 max-h-32 overflow-y-auto custom-scrollbar">{geminiKeys.map((k, idx) => (<div key={idx} className="flex gap-2 items-center bg-white p-2 rounded border border-gray-200"><div className="flex-1 font-mono text-xs text-gray-600 truncate">{k.substring(0, 8)}...{k.substring(k.length - 6)}</div><button onClick={() => removeKey('gemini', idx)} className="text-gray-400 hover:text-red-500"><Trash2 size={14}/></button></div>))}</div>
204
+ <div className="flex gap-2"><input className="flex-1 border border-gray-300 rounded px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-blue-500" placeholder="输入 Gemini API Key" value={newGeminiKey} onChange={e => setNewGeminiKey(e.target.value)}/><button onClick={() => handleAddKey('gemini')} className="bg-white hover:bg-gray-100 text-gray-600 px-2 py-1 rounded border border-gray-300"><Plus size={14}/></button></div>
205
+ </div>
206
+ {/* Doubao Keys */}
207
+ <div className="bg-gray-50/50 p-3 rounded-xl border border-gray-100">
208
+ <div className="flex items-center justify-between mb-2"><label className="text-sm font-bold text-gray-700">Doubao (豆包/火山引擎)</label><span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{doubaoKeys.length} 个</span></div>
209
+ <p className="text-[10px] text-gray-400 mb-3">使用原生 Axios 调用。</p>
210
+ <div className="space-y-2 mb-3 max-h-32 overflow-y-auto custom-scrollbar">{doubaoKeys.map((k, idx) => (<div key={idx} className="flex gap-2 items-center bg-white p-2 rounded border border-gray-200"><div className="flex-1 font-mono text-xs text-gray-600 truncate">{k.substring(0, 8)}...{k.substring(k.length - 6)}</div><button onClick={() => removeKey('doubao', idx)} className="text-gray-400 hover:text-red-500"><Trash2 size={14}/></button></div>))}</div>
211
+ <div className="flex gap-2"><input className="flex-1 border border-gray-300 rounded px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-green-500" placeholder="输入 Doubao API Key" value={newDoubaoKey} onChange={e => setNewDoubaoKey(e.target.value)}/><button onClick={() => handleAddKey('doubao')} className="bg-white hover:bg-gray-100 text-gray-600 px-2 py-1 rounded border border-gray-300"><Plus size={14}/></button></div>
212
  </div>
213
+ {/* OpenRouter Keys */}
214
+ <div className="bg-gray-50/50 p-3 rounded-xl border border-gray-100">
215
+ <div className="flex items-center justify-between mb-2"><label className="text-sm font-bold text-gray-700">OpenRouter (通用)</label><span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">{openRouterKeys.length} 个</span></div>
216
+ <p className="text-[10px] text-gray-400 mb-3">备用线路,支持自定义模型。</p>
217
+ <div className="space-y-2 mb-3 max-h-32 overflow-y-auto custom-scrollbar">{openRouterKeys.map((k, idx) => (<div key={idx} className="flex gap-2 items-center bg-white p-2 rounded border border-gray-200"><div className="flex-1 font-mono text-xs text-gray-600 truncate">{k.substring(0, 8)}...{k.substring(k.length - 6)}</div><button onClick={() => removeKey('openrouter', idx)} className="text-gray-400 hover:text-red-500"><Trash2 size={14}/></button></div>))}</div>
218
+ <div className="flex gap-2"><input className="flex-1 border border-gray-300 rounded px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-purple-500" placeholder="输入 OpenRouter Key" value={newOpenRouterKey} onChange={e => setNewOpenRouterKey(e.target.value)}/><button onClick={() => handleAddKey('openrouter')} className="bg-white hover:bg-gray-100 text-gray-600 px-2 py-1 rounded border border-gray-300"><Plus size={14}/></button></div>
219
  </div>
220
  </div>
221
 
 
252
  </div>
253
  </div>
254
  );
255
+ };
models.js CHANGED
@@ -26,6 +26,7 @@ const UserSchema = new mongoose.Schema({
26
  seatNo: String,
27
  idCard: String,
28
  aiAccess: { type: Boolean, default: false },
 
29
  menuOrder: [String], // NEW
30
  classApplication: {
31
  type: { type: String },
@@ -125,7 +126,8 @@ const ConfigSchema = new mongoose.Schema({
125
  periodConfig: [{ period: Number, name: String, startTime: String, endTime: String }],
126
  apiKeys: {
127
  gemini: [String],
128
- openrouter: [String]
 
129
  },
130
  openRouterModels: [{
131
  id: String,
@@ -294,4 +296,4 @@ module.exports = {
294
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
295
  AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
296
  WishModel, FeedbackModel, TodoModel, AIUsageModel, ChatHistoryModel
297
- };
 
26
  seatNo: String,
27
  idCard: String,
28
  aiAccess: { type: Boolean, default: false },
29
+ doubaoContextId: { type: String }, // NEW: Stores the Doubao Context ID for this user
30
  menuOrder: [String], // NEW
31
  classApplication: {
32
  type: { type: String },
 
126
  periodConfig: [{ period: Number, name: String, startTime: String, endTime: String }],
127
  apiKeys: {
128
  gemini: [String],
129
+ openrouter: [String],
130
+ doubao: [String] // NEW: Doubao Key Pool
131
  },
132
  openRouterModels: [{
133
  id: String,
 
296
  ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
297
  AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
298
  WishModel, FeedbackModel, TodoModel, AIUsageModel, ChatHistoryModel
299
+ };
types.ts CHANGED
@@ -189,11 +189,12 @@ export interface SystemConfig {
189
  emailNotify: boolean;
190
  enableAI?: boolean;
191
  aiTotalCalls?: number;
192
- aiProviderOrder?: string[]; // 'GEMINI', 'OPENROUTER', 'GEMMA'
193
  periodConfig?: PeriodConfig[];
194
  apiKeys?: {
195
  gemini?: string[];
196
  openrouter?: string[];
 
197
  };
198
  openRouterModels?: OpenRouterModelConfig[];
199
  }
@@ -388,4 +389,4 @@ export interface AIChatMessage {
388
  audio?: string;
389
  isAudioMessage?: boolean;
390
  timestamp: number;
391
- }
 
189
  emailNotify: boolean;
190
  enableAI?: boolean;
191
  aiTotalCalls?: number;
192
+ aiProviderOrder?: string[]; // 'GEMINI', 'OPENROUTER', 'GEMMA', 'DOUBAO'
193
  periodConfig?: PeriodConfig[];
194
  apiKeys?: {
195
  gemini?: string[];
196
  openrouter?: string[];
197
+ doubao?: string[]; // NEW: Doubao keys
198
  };
199
  openRouterModels?: OpenRouterModelConfig[];
200
  }
 
389
  audio?: string;
390
  isAudioMessage?: boolean;
391
  timestamp: number;
392
+ }