dvc890 commited on
Commit
3b149dd
·
verified ·
1 Parent(s): 2bf9484

Upload 58 files

Browse files
Files changed (4) hide show
  1. ai-routes.js +30 -6
  2. models.js +1 -0
  3. pages/AIAssistant.tsx +41 -2
  4. types.ts +1 -0
ai-routes.js CHANGED
@@ -60,13 +60,18 @@ function convertGeminiToOpenAI(baseParams) {
60
 
61
  const PROVIDERS = { GEMINI: 'GEMINI', OPENROUTER: 'OPENROUTER', GEMMA: 'GEMMA' };
62
  const DEFAULT_OPENROUTER_MODELS = ['qwen/qwen3-coder:free', 'openai/gpt-oss-120b:free', 'qwen/qwen3-235b-a22b:free', 'tngtech/deepseek-r1t-chimera:free'];
63
- let activeProviderOrder = [PROVIDERS.GEMINI, PROVIDERS.OPENROUTER, PROVIDERS.GEMMA];
 
 
64
 
65
  function deprioritizeProvider(providerName) {
66
- if (activeProviderOrder[activeProviderOrder.length - 1] === providerName) return;
67
- activeProviderOrder = activeProviderOrder.filter(p => p !== providerName).concat(providerName);
 
 
 
68
  }
69
- function resetProviderOrder() { activeProviderOrder = [PROVIDERS.GEMINI, PROVIDERS.OPENROUTER, PROVIDERS.GEMMA]; }
70
  function isQuotaError(e) {
71
  const msg = (e.message || '').toLowerCase();
72
  return e.status === 429 || e.status === 503 || msg.includes('quota') || msg.includes('overloaded') || msg.includes('resource_exhausted') || msg.includes('rate limit') || msg.includes('credits');
@@ -171,8 +176,24 @@ async function streamContentWithSmartFallback(baseParams, res) {
171
  if (hasAudio) {
172
  try { return await streamGemini(baseParams, res); } catch(e) { deprioritizeProvider(PROVIDERS.GEMINI); throw new Error('QUOTA_EXCEEDED_AUDIO'); }
173
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  let finalError = null;
175
- for (const provider of activeProviderOrder) {
 
 
176
  try {
177
  if (provider === PROVIDERS.GEMINI) return await streamGemini(baseParams, res);
178
  else if (provider === PROVIDERS.OPENROUTER) return await streamOpenRouter(baseParams, res);
@@ -226,7 +247,10 @@ router.get('/stats', checkAIAccess, async (req, res) => {
226
  } catch (e) { res.status(500).json({ error: e.message }); }
227
  });
228
 
229
- router.post('/reset-pool', checkAIAccess, (req, res) => { resetProviderOrder(); res.json({ success: true }); });
 
 
 
230
 
231
  router.post('/chat', checkAIAccess, async (req, res) => {
232
  const { text, audio, history } = req.body;
 
60
 
61
  const PROVIDERS = { GEMINI: 'GEMINI', OPENROUTER: 'OPENROUTER', GEMMA: 'GEMMA' };
62
  const DEFAULT_OPENROUTER_MODELS = ['qwen/qwen3-coder:free', 'openai/gpt-oss-120b:free', 'qwen/qwen3-235b-a22b:free', 'tngtech/deepseek-r1t-chimera:free'];
63
+
64
+ // Runtime override logic
65
+ let runtimeProviderOrder = [];
66
 
67
  function deprioritizeProvider(providerName) {
68
+ // If the provider is already last, do nothing
69
+ if (runtimeProviderOrder.length > 0 && runtimeProviderOrder[runtimeProviderOrder.length - 1] === providerName) return;
70
+
71
+ // Move to end
72
+ runtimeProviderOrder = runtimeProviderOrder.filter(p => p !== providerName).concat(providerName);
73
  }
74
+
75
  function isQuotaError(e) {
76
  const msg = (e.message || '').toLowerCase();
77
  return e.status === 429 || e.status === 503 || msg.includes('quota') || msg.includes('overloaded') || msg.includes('resource_exhausted') || msg.includes('rate limit') || msg.includes('credits');
 
176
  if (hasAudio) {
177
  try { return await streamGemini(baseParams, res); } catch(e) { deprioritizeProvider(PROVIDERS.GEMINI); throw new Error('QUOTA_EXCEEDED_AUDIO'); }
178
  }
179
+
180
+ // FETCH CONFIG AND SET PROVIDER ORDER
181
+ const config = await ConfigModel.findOne({ key: 'main' });
182
+ const configuredOrder = config?.aiProviderOrder && config.aiProviderOrder.length > 0
183
+ ? config.aiProviderOrder
184
+ : [PROVIDERS.GEMINI, PROVIDERS.OPENROUTER, PROVIDERS.GEMMA];
185
+
186
+ // If runtime order is empty or contains different elements (e.g. config changed), reset it
187
+ const runtimeSet = new Set(runtimeProviderOrder);
188
+ const configSet = new Set(configuredOrder);
189
+ if (runtimeProviderOrder.length === 0 || runtimeProviderOrder.length !== configuredOrder.length || !configuredOrder.every(p => runtimeSet.has(p))) {
190
+ runtimeProviderOrder = [...configuredOrder];
191
+ }
192
+
193
  let finalError = null;
194
+
195
+ // Use runtimeProviderOrder which might have been adjusted due to quota errors in previous calls
196
+ for (const provider of runtimeProviderOrder) {
197
  try {
198
  if (provider === PROVIDERS.GEMINI) return await streamGemini(baseParams, res);
199
  else if (provider === PROVIDERS.OPENROUTER) return await streamOpenRouter(baseParams, res);
 
247
  } catch (e) { res.status(500).json({ error: e.message }); }
248
  });
249
 
250
+ router.post('/reset-pool', checkAIAccess, (req, res) => {
251
+ runtimeProviderOrder = []; // Will be re-initialized from DB on next call
252
+ res.json({ success: true });
253
+ });
254
 
255
  router.post('/chat', checkAIAccess, async (req, res) => {
256
  const { text, audio, history } = req.body;
models.js CHANGED
@@ -115,6 +115,7 @@ const ConfigSchema = new mongoose.Schema({
115
  emailNotify: Boolean,
116
  enableAI: { type: Boolean, default: true },
117
  aiTotalCalls: { type: Number, default: 0 },
 
118
  periodConfig: [{ period: Number, name: String, startTime: String, endTime: String }],
119
  apiKeys: {
120
  gemini: [String],
 
115
  emailNotify: Boolean,
116
  enableAI: { type: Boolean, default: true },
117
  aiTotalCalls: { type: Number, default: 0 },
118
+ aiProviderOrder: { type: [String], default: ['GEMINI', 'OPENROUTER', 'GEMMA'] }, // NEW
119
  periodConfig: [{ period: Number, name: String, startTime: String, endTime: String }],
120
  apiKeys: {
121
  gemini: [String],
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, 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, BarChart2, Zap } from 'lucide-react';
6
  import ReactMarkdown from 'react-markdown';
7
  import remarkGfm from 'remark-gfm';
8
  import { Toast, ToastState } from '../components/Toast';
@@ -206,6 +206,9 @@ export const AIAssistant: React.FC = () => {
206
  const [newModelName, setNewModelName] = useState('');
207
  const [newModelApiUrl, setNewModelApiUrl] = useState('');
208
 
 
 
 
209
  // Audio Refs
210
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
211
  const audioChunksRef = useRef<Blob[]>([]);
@@ -230,6 +233,10 @@ export const AIAssistant: React.FC = () => {
230
  }
231
  setOrModels(cfg.openRouterModels && cfg.openRouterModels.length > 0 ? cfg.openRouterModels : DEFAULT_OR_MODELS);
232
 
 
 
 
 
233
  // Fetch Detailed Stats
234
  const stats = await api.ai.getStats();
235
  setDetailedStats(stats);
@@ -616,10 +623,21 @@ export const AIAssistant: React.FC = () => {
616
  [newArr[idx], newArr[targetIdx]] = [newArr[targetIdx], newArr[idx]];
617
  setOrModels(newArr);
618
  };
 
 
 
 
 
 
619
  const saveApiKeys = async () => {
620
  if (!systemConfig) return;
621
  try {
622
- await api.config.save({ ...systemConfig, apiKeys: { gemini: geminiKeys, openrouter: openRouterKeys }, openRouterModels: orModels });
 
 
 
 
 
623
  await api.ai.resetPool();
624
  setToast({ show: true, message: 'API 配置及模型列表已保存', type: 'success' });
625
  } catch (e) { setToast({ show: true, message: '保存失败', type: 'error' }); }
@@ -699,6 +717,27 @@ export const AIAssistant: React.FC = () => {
699
  <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>
700
  </div>
701
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
  <div className="mt-8 border-t border-gray-100 pt-6">
703
  <div className="flex justify-between items-center mb-4"><h4 className="font-bold text-gray-700 text-sm">OpenAI 格式大模型列表管理</h4></div>
704
  <div className="space-y-2 mb-4 bg-gray-50 p-3 rounded-lg border border-gray-200">{orModels.map((m, idx) => (<div key={idx} className="flex items-center gap-2 bg-white p-2 rounded border border-gray-100 shadow-sm"><div className="flex flex-col gap-0.5 px-1"><button onClick={()=>handleMoveModel(idx, -1)} className="text-gray-400 hover:text-blue-500 disabled:opacity-30" disabled={idx===0}><ArrowUp size={12}/></button><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></div><div className="flex-1 min-w-0"><div className="text-sm font-bold text-gray-800">{m.name || m.id}</div><div className="text-xs text-gray-400 font-mono truncate" title={m.id}>ID: {m.id}</div>{m.apiUrl && <div className="text-[10px] text-blue-500 truncate" title={m.apiUrl}>API: {m.apiUrl}</div>}</div><div className="flex items-center gap-2">{m.isCustom ? (<span className="text-[10px] bg-blue-50 text-blue-600 px-2 py-0.5 rounded">自定义</span>) : (<span className="text-[10px] bg-gray-100 text-gray-500 px-2 py-0.5 rounded">内置</span>)}<button onClick={() => handleRemoveModel(idx)} 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'}`} disabled={!m.isCustom}><Trash2 size={16}/></button></div></div>))}</div>
 
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, BarChart2, Zap, Layers } from 'lucide-react';
6
  import ReactMarkdown from 'react-markdown';
7
  import remarkGfm from 'remark-gfm';
8
  import { Toast, ToastState } from '../components/Toast';
 
206
  const [newModelName, setNewModelName] = useState('');
207
  const [newModelApiUrl, setNewModelApiUrl] = useState('');
208
 
209
+ // Admin Provider Priority State
210
+ const [providerOrder, setProviderOrder] = useState<string[]>(['GEMINI', 'OPENROUTER', 'GEMMA']);
211
+
212
  // Audio Refs
213
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
214
  const audioChunksRef = useRef<Blob[]>([]);
 
233
  }
234
  setOrModels(cfg.openRouterModels && cfg.openRouterModels.length > 0 ? cfg.openRouterModels : DEFAULT_OR_MODELS);
235
 
236
+ if (cfg.aiProviderOrder && cfg.aiProviderOrder.length > 0) {
237
+ setProviderOrder(cfg.aiProviderOrder);
238
+ }
239
+
240
  // Fetch Detailed Stats
241
  const stats = await api.ai.getStats();
242
  setDetailedStats(stats);
 
623
  [newArr[idx], newArr[targetIdx]] = [newArr[targetIdx], newArr[idx]];
624
  setOrModels(newArr);
625
  };
626
+ const handleMoveProviderOrder = (idx: number, direction: -1 | 1) => {
627
+ const newArr = [...providerOrder]; const targetIdx = idx + direction;
628
+ if (targetIdx < 0 || targetIdx >= newArr.length) return;
629
+ [newArr[idx], newArr[targetIdx]] = [newArr[targetIdx], newArr[idx]];
630
+ setProviderOrder(newArr);
631
+ };
632
  const saveApiKeys = async () => {
633
  if (!systemConfig) return;
634
  try {
635
+ await api.config.save({
636
+ ...systemConfig,
637
+ apiKeys: { gemini: geminiKeys, openrouter: openRouterKeys },
638
+ openRouterModels: orModels,
639
+ aiProviderOrder: providerOrder
640
+ });
641
  await api.ai.resetPool();
642
  setToast({ show: true, message: 'API 配置及模型列表已保存', type: 'success' });
643
  } catch (e) { setToast({ show: true, message: '保存失败', type: 'error' }); }
 
717
  <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>
718
  </div>
719
  </div>
720
+
721
+ {/* Provider Order Management */}
722
+ <div className="mt-8 border-t border-gray-100 pt-6">
723
+ <div className="flex justify-between items-center mb-4"><h4 className="font-bold text-gray-700 text-sm flex items-center"><Layers className="mr-2" size={16}/> 大模型调用优先级</h4></div>
724
+ <div className="bg-amber-50 p-4 rounded-lg border border-amber-100 mb-4">
725
+ <p className="text-xs text-amber-800">系统将按照以下顺序尝试调用大模型。如果前一个服务商额度耗尽或报错,会自动切换到下一个。</p>
726
+ </div>
727
+ <div className="space-y-2 max-w-md">
728
+ {providerOrder.map((provider, idx) => (
729
+ <div key={provider} className="flex items-center gap-3 bg-white p-3 rounded-lg border border-gray-200 shadow-sm">
730
+ <div className="bg-gray-100 text-gray-500 w-6 h-6 flex items-center justify-center rounded-full text-xs font-bold">{idx + 1}</div>
731
+ <div className="flex-1 font-bold text-gray-700">{provider}</div>
732
+ <div className="flex gap-1">
733
+ <button onClick={() => handleMoveProviderOrder(idx, -1)} disabled={idx === 0} className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-blue-500 disabled:opacity-30"><ArrowUp size={16}/></button>
734
+ <button onClick={() => handleMoveProviderOrder(idx, 1)} disabled={idx === providerOrder.length - 1} className="p-1 hover:bg-gray-100 rounded text-gray-400 hover:text-blue-500 disabled:opacity-30"><ArrowDown size={16}/></button>
735
+ </div>
736
+ </div>
737
+ ))}
738
+ </div>
739
+ </div>
740
+
741
  <div className="mt-8 border-t border-gray-100 pt-6">
742
  <div className="flex justify-between items-center mb-4"><h4 className="font-bold text-gray-700 text-sm">OpenAI 格式大模型列表管理</h4></div>
743
  <div className="space-y-2 mb-4 bg-gray-50 p-3 rounded-lg border border-gray-200">{orModels.map((m, idx) => (<div key={idx} className="flex items-center gap-2 bg-white p-2 rounded border border-gray-100 shadow-sm"><div className="flex flex-col gap-0.5 px-1"><button onClick={()=>handleMoveModel(idx, -1)} className="text-gray-400 hover:text-blue-500 disabled:opacity-30" disabled={idx===0}><ArrowUp size={12}/></button><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></div><div className="flex-1 min-w-0"><div className="text-sm font-bold text-gray-800">{m.name || m.id}</div><div className="text-xs text-gray-400 font-mono truncate" title={m.id}>ID: {m.id}</div>{m.apiUrl && <div className="text-[10px] text-blue-500 truncate" title={m.apiUrl}>API: {m.apiUrl}</div>}</div><div className="flex items-center gap-2">{m.isCustom ? (<span className="text-[10px] bg-blue-50 text-blue-600 px-2 py-0.5 rounded">自定义</span>) : (<span className="text-[10px] bg-gray-100 text-gray-500 px-2 py-0.5 rounded">内置</span>)}<button onClick={() => handleRemoveModel(idx)} 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'}`} disabled={!m.isCustom}><Trash2 size={16}/></button></div></div>))}</div>
types.ts CHANGED
@@ -184,6 +184,7 @@ export interface SystemConfig {
184
  emailNotify: boolean;
185
  enableAI?: boolean;
186
  aiTotalCalls?: number;
 
187
  periodConfig?: PeriodConfig[];
188
  apiKeys?: {
189
  gemini?: string[];
 
184
  emailNotify: boolean;
185
  enableAI?: boolean;
186
  aiTotalCalls?: number;
187
+ aiProviderOrder?: string[]; // 'GEMINI', 'OPENROUTER', 'GEMMA'
188
  periodConfig?: PeriodConfig[];
189
  apiKeys?: {
190
  gemini?: string[];