Spaces:
Running
Running
Upload 53 files
Browse files- ai-routes.js +25 -9
- models.js +6 -1
- pages/AIAssistant.tsx +127 -14
- 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 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
+
}
|