dvc890 commited on
Commit
36325f8
·
verified ·
1 Parent(s): e1974ef

Upload 53 files

Browse files
Files changed (4) hide show
  1. ai-routes.js +25 -9
  2. models.js +6 -1
  3. pages/AIAssistant.tsx +127 -14
  4. types.ts +8 -1
ai-routes.js CHANGED
@@ -95,6 +95,13 @@ const PROVIDERS = {
95
  GEMMA: 'GEMMA'
96
  };
97
 
 
 
 
 
 
 
 
98
  let activeProviderOrder = [PROVIDERS.GEMINI, PROVIDERS.OPENROUTER, PROVIDERS.GEMMA];
99
 
100
  function deprioritizeProvider(providerName) {
@@ -159,14 +166,16 @@ async function callGeminiProvider(baseParams) {
159
  }
160
 
161
  async function callOpenRouterProvider(baseParams) {
162
- const openRouterModels = [
163
- 'qwen/qwen3-coder:free',
164
- 'openai/gpt-oss-120b:free',
165
- 'qwen/qwen3-235b-a22b:free',
166
- 'tngtech/deepseek-r1t-chimera:free'
167
- ];
 
 
 
168
  const openAIMessages = convertGeminiToOpenAI(baseParams);
169
-
170
  const keys = await getKeyPool('openrouter');
171
  if (keys.length === 0) throw new Error("No OpenRouter API keys configured");
172
 
@@ -310,9 +319,16 @@ async function streamGemini(baseParams, res) {
310
  }
311
 
312
  async function streamOpenRouter(baseParams, res) {
313
- const openRouterModels = ['qwen/qwen3-coder:free', 'openai/gpt-oss-120b:free'];
 
 
 
 
 
 
 
 
314
  const messages = convertGeminiToOpenAI(baseParams);
315
-
316
  const keys = await getKeyPool('openrouter');
317
  if (keys.length === 0) throw new Error("No OpenRouter API keys");
318
 
 
95
  GEMMA: 'GEMMA'
96
  };
97
 
98
+ const DEFAULT_OPENROUTER_MODELS = [
99
+ 'qwen/qwen3-coder:free',
100
+ 'openai/gpt-oss-120b:free',
101
+ 'qwen/qwen3-235b-a22b:free',
102
+ 'tngtech/deepseek-r1t-chimera:free'
103
+ ];
104
+
105
  let activeProviderOrder = [PROVIDERS.GEMINI, PROVIDERS.OPENROUTER, PROVIDERS.GEMMA];
106
 
107
  function deprioritizeProvider(providerName) {
 
166
  }
167
 
168
  async function callOpenRouterProvider(baseParams) {
169
+ // Retrieve model list from DB
170
+ const config = await ConfigModel.findOne({ key: 'main' });
171
+ let openRouterModels = [];
172
+ if (config && config.openRouterModels && config.openRouterModels.length > 0) {
173
+ openRouterModels = config.openRouterModels.map(m => m.id);
174
+ } else {
175
+ openRouterModels = DEFAULT_OPENROUTER_MODELS;
176
+ }
177
+
178
  const openAIMessages = convertGeminiToOpenAI(baseParams);
 
179
  const keys = await getKeyPool('openrouter');
180
  if (keys.length === 0) throw new Error("No OpenRouter API keys configured");
181
 
 
319
  }
320
 
321
  async function streamOpenRouter(baseParams, res) {
322
+ // Retrieve model list from DB
323
+ const config = await ConfigModel.findOne({ key: 'main' });
324
+ let openRouterModels = [];
325
+ if (config && config.openRouterModels && config.openRouterModels.length > 0) {
326
+ openRouterModels = config.openRouterModels.map(m => m.id);
327
+ } else {
328
+ openRouterModels = DEFAULT_OPENROUTER_MODELS;
329
+ }
330
+
331
  const messages = convertGeminiToOpenAI(baseParams);
 
332
  const keys = await getKeyPool('openrouter');
333
  if (keys.length === 0) throw new Error("No OpenRouter API keys");
334
 
models.js CHANGED
@@ -120,7 +120,12 @@ const ConfigSchema = new mongoose.Schema({
120
  apiKeys: {
121
  gemini: [String],
122
  openrouter: [String]
123
- }
 
 
 
 
 
124
  });
125
  const ConfigModel = mongoose.model('Config', ConfigSchema);
126
 
 
120
  apiKeys: {
121
  gemini: [String],
122
  openrouter: [String]
123
+ },
124
+ openRouterModels: [{
125
+ id: String,
126
+ name: String,
127
+ isCustom: { type: Boolean, default: false }
128
+ }]
129
  });
130
  const ConfigModel = mongoose.model('Config', ConfigSchema);
131
 
pages/AIAssistant.tsx CHANGED
@@ -1,8 +1,8 @@
1
 
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';
@@ -85,6 +85,13 @@ const cleanTextForTTS = (text: string) => {
85
  .trim();
86
  };
87
 
 
 
 
 
 
 
 
88
  export const AIAssistant: React.FC = () => {
89
  const currentUser = api.auth.getCurrentUser();
90
  const isAdmin = currentUser?.role === UserRole.ADMIN;
@@ -97,13 +104,22 @@ export const AIAssistant: React.FC = () => {
97
  // Chat State with Persistence
98
  const [activeTab, setActiveTab] = useState<'chat' | 'assessment'>('chat');
99
  const [messages, setMessages] = useState<AIChatMessage[]>(() => {
100
- const saved = localStorage.getItem('ai_chat_history');
101
- return saved ? JSON.parse(saved) : [{
102
- id: 'welcome',
103
- role: 'model',
104
- text: '你好!我是你的 AI 智能助教。有什么可以帮你的吗?',
105
- timestamp: Date.now()
106
- }];
 
 
 
 
 
 
 
 
 
107
  });
108
  const [textInput, setTextInput] = useState('');
109
  const [inputMode, setInputMode] = useState<'text' | 'audio'>('text');
@@ -121,6 +137,11 @@ export const AIAssistant: React.FC = () => {
121
  const [openRouterKeys, setOpenRouterKeys] = useState<string[]>([]);
122
  const [newGeminiKey, setNewGeminiKey] = useState('');
123
  const [newOpenRouterKey, setNewOpenRouterKey] = useState('');
 
 
 
 
 
124
 
125
  // Audio Refs
126
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
@@ -143,6 +164,9 @@ export const AIAssistant: React.FC = () => {
143
  setGeminiKeys(cfg.apiKeys.gemini || []);
144
  setOpenRouterKeys(cfg.apiKeys.openrouter || []);
145
  }
 
 
 
146
  } catch (e) {
147
  console.error("Init failed", e);
148
  } finally {
@@ -157,9 +181,25 @@ export const AIAssistant: React.FC = () => {
157
  };
158
  }, []);
159
 
160
- // Persist messages
161
  useEffect(() => {
162
- localStorage.setItem('ai_chat_history', JSON.stringify(messages));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  }, [messages]);
164
 
165
  useEffect(() => {
@@ -455,6 +495,30 @@ export const AIAssistant: React.FC = () => {
455
  }
456
  };
457
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  const saveApiKeys = async () => {
459
  if (!systemConfig) return;
460
  try {
@@ -463,12 +527,13 @@ export const AIAssistant: React.FC = () => {
463
  apiKeys: {
464
  gemini: geminiKeys,
465
  openrouter: openRouterKeys
466
- }
 
467
  });
468
  // NEW: Reset provider priority pool immediately after saving
469
  await api.ai.resetPool();
470
 
471
- setToast({ show: true, message: 'API 密钥配置已保存并生效 (线路优先级已重置)', type: 'success' });
472
  } catch (e) {
473
  setToast({ show: true, message: '保存失败', type: 'error' });
474
  }
@@ -528,7 +593,7 @@ export const AIAssistant: React.FC = () => {
528
  <div className="flex justify-between items-center mb-6">
529
  <h3 className="font-bold text-gray-800 flex items-center"><Key className="mr-2 text-amber-500"/> 多线路密钥池配置</h3>
530
  <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">
531
- <Save size={16}/> 保存密钥配置
532
  </button>
533
  </div>
534
 
@@ -589,6 +654,54 @@ export const AIAssistant: React.FC = () => {
589
  </div>
590
  </div>
591
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  </div>
593
  </div>
594
  );
 
1
 
2
  import React, { useState, useRef, useEffect } from 'react';
3
  import { api } from '../services/api';
4
+ import { AIChatMessage, SystemConfig, UserRole, OpenRouterModelConfig } 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, ArrowUp, ArrowDown } from 'lucide-react';
6
  import ReactMarkdown from 'react-markdown';
7
  import remarkGfm from 'remark-gfm';
8
  import { Toast, ToastState } from '../components/Toast';
 
85
  .trim();
86
  };
87
 
88
+ const DEFAULT_OR_MODELS = [
89
+ { id: 'qwen/qwen3-coder:free', name: 'Qwen 3 Coder', isCustom: false },
90
+ { id: 'openai/gpt-oss-120b:free', name: 'GPT OSS 120B', isCustom: false },
91
+ { id: 'qwen/qwen3-235b-a22b:free', name: 'Qwen 3 235B', isCustom: false },
92
+ { id: 'tngtech/deepseek-r1t-chimera:free', name: 'DeepSeek R1T', isCustom: false }
93
+ ];
94
+
95
  export const AIAssistant: React.FC = () => {
96
  const currentUser = api.auth.getCurrentUser();
97
  const isAdmin = currentUser?.role === UserRole.ADMIN;
 
104
  // Chat State with Persistence
105
  const [activeTab, setActiveTab] = useState<'chat' | 'assessment'>('chat');
106
  const [messages, setMessages] = useState<AIChatMessage[]>(() => {
107
+ try {
108
+ const saved = localStorage.getItem('ai_chat_history');
109
+ return saved ? JSON.parse(saved) : [{
110
+ id: 'welcome',
111
+ role: 'model',
112
+ text: '你好!我是你的 AI 智能助教。有什么可以帮你的吗?',
113
+ timestamp: Date.now()
114
+ }];
115
+ } catch (e) {
116
+ return [{
117
+ id: 'welcome',
118
+ role: 'model',
119
+ text: '你好!我是你的 AI 智能助教。有什么可以帮你的吗?',
120
+ timestamp: Date.now()
121
+ }];
122
+ }
123
  });
124
  const [textInput, setTextInput] = useState('');
125
  const [inputMode, setInputMode] = useState<'text' | 'audio'>('text');
 
137
  const [openRouterKeys, setOpenRouterKeys] = useState<string[]>([]);
138
  const [newGeminiKey, setNewGeminiKey] = useState('');
139
  const [newOpenRouterKey, setNewOpenRouterKey] = useState('');
140
+
141
+ // Admin Model Management State
142
+ const [orModels, setOrModels] = useState<OpenRouterModelConfig[]>([]);
143
+ const [newModelId, setNewModelId] = useState('');
144
+ const [newModelName, setNewModelName] = useState('');
145
 
146
  // Audio Refs
147
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
 
164
  setGeminiKeys(cfg.apiKeys.gemini || []);
165
  setOpenRouterKeys(cfg.apiKeys.openrouter || []);
166
  }
167
+ if (isAdmin) {
168
+ setOrModels(cfg.openRouterModels && cfg.openRouterModels.length > 0 ? cfg.openRouterModels : DEFAULT_OR_MODELS);
169
+ }
170
  } catch (e) {
171
  console.error("Init failed", e);
172
  } finally {
 
181
  };
182
  }, []);
183
 
184
+ // Persist messages with strict count limit
185
  useEffect(() => {
186
+ try {
187
+ // Force strict limit: Welcome message + Last 50 messages
188
+ const MAX_COUNT = 50;
189
+ const welcome = messages.find(m => m.id === 'welcome');
190
+ const others = messages.filter(m => m.id !== 'welcome');
191
+
192
+ // Slice the last 50
193
+ const recent = others.slice(-MAX_COUNT);
194
+
195
+ // Reconstruct array: Welcome (if exists) + Recent
196
+ const messagesToSave = (welcome ? [welcome] : []).concat(recent);
197
+
198
+ localStorage.setItem('ai_chat_history', JSON.stringify(messagesToSave));
199
+ } catch (e) {
200
+ console.warn("LocalStorage save failed despite count limit (Quota Exceeded). History not persisted.", e);
201
+ // Catch error to prevent crash. This usually happens if the 50 messages contain VERY large audio files exceeding 5MB total.
202
+ }
203
  }, [messages]);
204
 
205
  useEffect(() => {
 
495
  }
496
  };
497
 
498
+ // --- Model Management Handlers ---
499
+ const handleAddModel = () => {
500
+ if (!newModelId.trim()) return;
501
+ setOrModels([...orModels, {
502
+ id: newModelId.trim(),
503
+ name: newModelName.trim() || newModelId.trim(),
504
+ isCustom: true
505
+ }]);
506
+ setNewModelId('');
507
+ setNewModelName('');
508
+ };
509
+
510
+ const handleRemoveModel = (idx: number) => {
511
+ setOrModels(orModels.filter((_, i) => i !== idx));
512
+ };
513
+
514
+ const handleMoveModel = (idx: number, direction: -1 | 1) => {
515
+ const newArr = [...orModels];
516
+ const targetIdx = idx + direction;
517
+ if (targetIdx < 0 || targetIdx >= newArr.length) return;
518
+ [newArr[idx], newArr[targetIdx]] = [newArr[targetIdx], newArr[idx]];
519
+ setOrModels(newArr);
520
+ };
521
+
522
  const saveApiKeys = async () => {
523
  if (!systemConfig) return;
524
  try {
 
527
  apiKeys: {
528
  gemini: geminiKeys,
529
  openrouter: openRouterKeys
530
+ },
531
+ openRouterModels: orModels
532
  });
533
  // NEW: Reset provider priority pool immediately after saving
534
  await api.ai.resetPool();
535
 
536
+ setToast({ show: true, message: 'API 配置及模型列表已保存', type: 'success' });
537
  } catch (e) {
538
  setToast({ show: true, message: '保存失败', type: 'error' });
539
  }
 
593
  <div className="flex justify-between items-center mb-6">
594
  <h3 className="font-bold text-gray-800 flex items-center"><Key className="mr-2 text-amber-500"/> 多线路密钥池配置</h3>
595
  <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">
596
+ <Save size={16}/> 保存所有配置
597
  </button>
598
  </div>
599
 
 
654
  </div>
655
  </div>
656
  </div>
657
+
658
+ {/* OpenRouter Model Management */}
659
+ <div className="mt-8 border-t border-gray-100 pt-6">
660
+ <div className="flex justify-between items-center mb-4">
661
+ <h4 className="font-bold text-gray-700 text-sm">OpenRouter 大模型列表管理</h4>
662
+ <span className="text-xs text-gray-400">优先尝试列表顶部的模型</span>
663
+ </div>
664
+ <div className="space-y-2 mb-4 bg-gray-50 p-3 rounded-lg border border-gray-200">
665
+ {orModels.map((m, idx) => (
666
+ <div key={idx} className="flex items-center gap-2 bg-white p-2 rounded border border-gray-100 shadow-sm">
667
+ <div className="flex flex-col gap-0.5 px-1">
668
+ <button onClick={()=>handleMoveModel(idx, -1)} className="text-gray-400 hover:text-blue-500 disabled:opacity-30" disabled={idx===0}><ArrowUp size={12}/></button>
669
+ <button onClick={()=>handleMoveModel(idx, 1)} className="text-gray-400 hover:text-blue-500 disabled:opacity-30" disabled={idx===orModels.length-1}><ArrowDown size={12}/></button>
670
+ </div>
671
+ <div className="flex-1">
672
+ <div className="text-sm font-bold text-gray-800">{m.name || m.id}</div>
673
+ <div className="text-xs text-gray-400 font-mono">{m.id}</div>
674
+ </div>
675
+ <div className="flex items-center gap-2">
676
+ {m.isCustom ? (
677
+ <span className="text-[10px] bg-blue-50 text-blue-600 px-2 py-0.5 rounded">自定义</span>
678
+ ) : (
679
+ <span className="text-[10px] bg-gray-100 text-gray-500 px-2 py-0.5 rounded">内置</span>
680
+ )}
681
+ <button
682
+ onClick={() => handleRemoveModel(idx)}
683
+ className={`p-1.5 rounded transition-colors ${m.isCustom ? 'text-gray-400 hover:text-red-500 hover:bg-red-50' : 'text-gray-200 cursor-not-allowed'}`}
684
+ disabled={!m.isCustom}
685
+ title={!m.isCustom ? "内置模型不可删除" : "删除模型"}
686
+ >
687
+ <Trash2 size={16}/>
688
+ </button>
689
+ </div>
690
+ </div>
691
+ ))}
692
+ </div>
693
+ <div className="flex gap-2 items-end bg-gray-50 p-3 rounded-lg border border-gray-200">
694
+ <div className="flex-1">
695
+ <label className="text-xs text-gray-500 mb-1 block">模型 ID (如: openai/gpt-4o)</label>
696
+ <input className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={newModelId} onChange={e=>setNewModelId(e.target.value)} placeholder="输入 OpenRouter 模型 ID"/>
697
+ </div>
698
+ <div className="flex-1">
699
+ <label className="text-xs text-gray-500 mb-1 block">显示名称 (可选)</label>
700
+ <input className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={newModelName} onChange={e=>setNewModelName(e.target.value)} placeholder="友好显示的名称"/>
701
+ </div>
702
+ <button onClick={handleAddModel} className="bg-indigo-600 text-white px-4 py-1.5 rounded text-sm hover:bg-indigo-700 h-9">添加</button>
703
+ </div>
704
+ </div>
705
  </div>
706
  </div>
707
  );
types.ts CHANGED
@@ -165,6 +165,12 @@ export interface Notification {
165
  createTime: string;
166
  }
167
 
 
 
 
 
 
 
168
  export interface SystemConfig {
169
  systemName: string;
170
  semester: string;
@@ -182,6 +188,7 @@ export interface SystemConfig {
182
  gemini?: string[];
183
  openrouter?: string[];
184
  };
 
185
  }
186
 
187
  export interface SchoolCalendarEntry {
@@ -372,4 +379,4 @@ export interface AIChatMessage {
372
  audio?: string;
373
  isAudioMessage?: boolean;
374
  timestamp: number;
375
- }
 
165
  createTime: string;
166
  }
167
 
168
+ export interface OpenRouterModelConfig {
169
+ id: string; // The model name (e.g., qwen/qwen3-coder:free)
170
+ name?: string; // Optional friendly name
171
+ isCustom: boolean; // Built-in vs Custom
172
+ }
173
+
174
  export interface SystemConfig {
175
  systemName: string;
176
  semester: string;
 
188
  gemini?: string[];
189
  openrouter?: string[];
190
  };
191
+ openRouterModels?: OpenRouterModelConfig[];
192
  }
193
 
194
  export interface SchoolCalendarEntry {
 
379
  audio?: string;
380
  isAudioMessage?: boolean;
381
  timestamp: number;
382
+ }