Spaces:
Runtime error
Runtime error
Upload 58 files
Browse files- ai-routes.js +30 -6
- models.js +1 -0
- pages/AIAssistant.tsx +41 -2
- 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 |
-
|
|
|
|
|
|
|
| 64 |
|
| 65 |
function deprioritizeProvider(providerName) {
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
| 68 |
}
|
| 69 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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) => {
|
|
|
|
|
|
|
|
|
|
| 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({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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[];
|