Spaces:
Sleeping
Sleeping
Upload 58 files
Browse files- ai-routes.js +8 -2
- models.js +1 -0
- pages/AIAssistant.tsx +66 -15
- pages/AchievementTeacher.tsx +1 -1
- types.ts +1 -0
ai-routes.js
CHANGED
|
@@ -107,9 +107,15 @@ async function streamOpenRouter(baseParams, res) {
|
|
| 107 |
const messages = convertGeminiToOpenAI(baseParams);
|
| 108 |
const keys = await getKeyPool('openrouter');
|
| 109 |
if (keys.length === 0) throw new Error("No OpenRouter API keys");
|
|
|
|
| 110 |
for (const apiKey of keys) {
|
| 111 |
-
const client = new OpenAI({ baseURL: "https://openrouter.ai/api/v1", apiKey, defaultHeaders: { "HTTP-Referer": "https://smart.com", "X-Title": "Smart School" } });
|
| 112 |
for (const modelName of models) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
try {
|
| 114 |
const stream = await client.chat.completions.create({ model: modelName, messages, stream: true });
|
| 115 |
recordUsage(modelName, PROVIDERS.OPENROUTER);
|
|
@@ -357,4 +363,4 @@ router.post('/evaluate', checkAIAccess, async (req, res) => {
|
|
| 357 |
}
|
| 358 |
});
|
| 359 |
|
| 360 |
-
module.exports = router;
|
|
|
|
| 107 |
const messages = convertGeminiToOpenAI(baseParams);
|
| 108 |
const keys = await getKeyPool('openrouter');
|
| 109 |
if (keys.length === 0) throw new Error("No OpenRouter API keys");
|
| 110 |
+
|
| 111 |
for (const apiKey of keys) {
|
|
|
|
| 112 |
for (const modelName of models) {
|
| 113 |
+
// Find specific model config to check for custom URL
|
| 114 |
+
const modelConfig = config?.openRouterModels?.find(m => m.id === modelName);
|
| 115 |
+
const baseURL = modelConfig?.apiUrl ? modelConfig.apiUrl : "https://openrouter.ai/api/v1";
|
| 116 |
+
|
| 117 |
+
const client = new OpenAI({ baseURL, apiKey, defaultHeaders: { "HTTP-Referer": "https://smart.com", "X-Title": "Smart School" } });
|
| 118 |
+
|
| 119 |
try {
|
| 120 |
const stream = await client.chat.completions.create({ model: modelName, messages, stream: true });
|
| 121 |
recordUsage(modelName, PROVIDERS.OPENROUTER);
|
|
|
|
| 363 |
}
|
| 364 |
});
|
| 365 |
|
| 366 |
+
module.exports = router;
|
models.js
CHANGED
|
@@ -123,6 +123,7 @@ const ConfigSchema = new mongoose.Schema({
|
|
| 123 |
openRouterModels: [{
|
| 124 |
id: String,
|
| 125 |
name: String,
|
|
|
|
| 126 |
isCustom: { type: Boolean, default: false }
|
| 127 |
}]
|
| 128 |
});
|
|
|
|
| 123 |
openRouterModels: [{
|
| 124 |
id: String,
|
| 125 |
name: String,
|
| 126 |
+
apiUrl: String, // NEW: Custom API URL for this model
|
| 127 |
isCustom: { type: Boolean, default: false }
|
| 128 |
}]
|
| 129 |
});
|
pages/AIAssistant.tsx
CHANGED
|
@@ -26,6 +26,45 @@ const blobToBase64 = (blob: Blob): Promise<string> => {
|
|
| 26 |
});
|
| 27 |
};
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
const fileToBase64 = (file: File): Promise<string> => {
|
| 30 |
return new Promise((resolve, reject) => {
|
| 31 |
const reader = new FileReader();
|
|
@@ -165,6 +204,7 @@ export const AIAssistant: React.FC = () => {
|
|
| 165 |
const [orModels, setOrModels] = useState<OpenRouterModelConfig[]>([]);
|
| 166 |
const [newModelId, setNewModelId] = useState('');
|
| 167 |
const [newModelName, setNewModelName] = useState('');
|
|
|
|
| 168 |
|
| 169 |
// Audio Refs
|
| 170 |
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
@@ -516,12 +556,13 @@ export const AIAssistant: React.FC = () => {
|
|
| 516 |
if (selectedImages.length === 0) return;
|
| 517 |
setAssessmentStatus('UPLOADING');
|
| 518 |
try {
|
| 519 |
-
|
|
|
|
| 520 |
const base64Images = await Promise.all(base64Promises);
|
| 521 |
handleAssessmentStreamingSubmit({ images: base64Images });
|
| 522 |
} catch(e) {
|
| 523 |
setAssessmentStatus('IDLE');
|
| 524 |
-
setToast({ show: true, message: '
|
| 525 |
}
|
| 526 |
};
|
| 527 |
|
|
@@ -559,8 +600,14 @@ export const AIAssistant: React.FC = () => {
|
|
| 559 |
};
|
| 560 |
const handleAddModel = () => {
|
| 561 |
if (!newModelId.trim()) return;
|
| 562 |
-
|
| 563 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
};
|
| 565 |
const handleRemoveModel = (idx: number) => setOrModels(orModels.filter((_, i) => i !== idx));
|
| 566 |
const handleMoveModel = (idx: number, direction: -1 | 1) => {
|
|
@@ -582,7 +629,6 @@ export const AIAssistant: React.FC = () => {
|
|
| 582 |
|
| 583 |
// --- ADMIN VIEW ---
|
| 584 |
if (isAdmin) {
|
| 585 |
-
// (Admin View JSX - Same as before, omitted for brevity as it was not part of the requested change logic, but included in full code return)
|
| 586 |
return (
|
| 587 |
<div className="p-6 md:p-10 max-w-5xl mx-auto space-y-8 animate-in fade-in pb-20 overflow-y-auto h-full">
|
| 588 |
{toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
|
|
@@ -647,16 +693,21 @@ export const AIAssistant: React.FC = () => {
|
|
| 647 |
<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>
|
| 648 |
</div>
|
| 649 |
<div>
|
| 650 |
-
<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>
|
| 651 |
-
<p className="text-xs text-gray-400 mb-3"
|
| 652 |
<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>
|
| 653 |
-
<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="输入
|
| 654 |
</div>
|
| 655 |
</div>
|
| 656 |
<div className="mt-8 border-t border-gray-100 pt-6">
|
| 657 |
-
<div className="flex justify-between items-center mb-4"><h4 className="font-bold text-gray-700 text-sm">
|
| 658 |
-
<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"><div className="text-sm font-bold text-gray-800">{m.name || m.id}</div><div className="text-xs text-gray-400 font-mono">{m.id}</div
|
| 659 |
-
<div className="flex gap-2 items-end bg-gray-50 p-3 rounded-lg border border-gray-200"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 660 |
</div>
|
| 661 |
</div>
|
| 662 |
</div>
|
|
@@ -665,7 +716,7 @@ export const AIAssistant: React.FC = () => {
|
|
| 665 |
|
| 666 |
if (!systemConfig?.enableAI) return <div className="flex flex-col items-center justify-center h-full text-center p-6 space-y-4"><div className="bg-gray-100 p-6 rounded-full"><Bot size={48} className="text-gray-400 grayscale"/></div><h2 className="text-xl font-bold text-gray-700">AI 助手维护中</h2><p className="text-gray-500 max-w-md">管理员已暂时关闭 AI 服务。</p></div>;
|
| 667 |
|
| 668 |
-
// ... (Main Render)
|
| 669 |
return (
|
| 670 |
<div className="h-full flex flex-col bg-slate-50 overflow-hidden relative">
|
| 671 |
{toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
|
|
@@ -751,7 +802,7 @@ export const AIAssistant: React.FC = () => {
|
|
| 751 |
<>
|
| 752 |
<ImageIcon className="mx-auto text-purple-300 mb-2" size={40}/>
|
| 753 |
<p className="text-purple-600 font-bold">点击上传作业图片</p>
|
| 754 |
-
<p className="text-xs text-gray-400">支持批量上传 •
|
| 755 |
</>
|
| 756 |
) : (
|
| 757 |
<div className="z-0 w-full pointer-events-none opacity-50 flex items-center justify-center">
|
|
@@ -784,7 +835,7 @@ export const AIAssistant: React.FC = () => {
|
|
| 784 |
className="mt-6 w-full px-8 py-3 bg-purple-600 text-white rounded-lg font-bold hover:bg-purple-700 flex items-center justify-center gap-2 shadow-md transition-all"
|
| 785 |
>
|
| 786 |
{assessmentStatus !== 'IDLE' ? <Loader2 className="animate-spin" size={18}/> : <CheckCircle size={18}/>}
|
| 787 |
-
{assessmentStatus === 'UPLOADING' ? '
|
| 788 |
</button>
|
| 789 |
)}
|
| 790 |
</div>
|
|
@@ -839,4 +890,4 @@ export const AIAssistant: React.FC = () => {
|
|
| 839 |
)}
|
| 840 |
</div>
|
| 841 |
);
|
| 842 |
-
};
|
|
|
|
| 26 |
});
|
| 27 |
};
|
| 28 |
|
| 29 |
+
// Optimized Image Compression Utility
|
| 30 |
+
const compressImage = (file: File, maxWidth = 1600, quality = 0.7): Promise<string> => {
|
| 31 |
+
return new Promise((resolve, reject) => {
|
| 32 |
+
const reader = new FileReader();
|
| 33 |
+
reader.readAsDataURL(file);
|
| 34 |
+
reader.onload = (event) => {
|
| 35 |
+
const img = new Image();
|
| 36 |
+
img.src = event.target?.result as string;
|
| 37 |
+
img.onload = () => {
|
| 38 |
+
const canvas = document.createElement('canvas');
|
| 39 |
+
let width = img.width;
|
| 40 |
+
let height = img.height;
|
| 41 |
+
|
| 42 |
+
if (width > maxWidth) {
|
| 43 |
+
height = Math.round((height * maxWidth) / width);
|
| 44 |
+
width = maxWidth;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
canvas.width = width;
|
| 48 |
+
canvas.height = height;
|
| 49 |
+
const ctx = canvas.getContext('2d');
|
| 50 |
+
if (!ctx) {
|
| 51 |
+
reject(new Error('Canvas context failed'));
|
| 52 |
+
return;
|
| 53 |
+
}
|
| 54 |
+
ctx.drawImage(img, 0, 0, width, height);
|
| 55 |
+
|
| 56 |
+
// Output as JPEG with quality reduction
|
| 57 |
+
const dataUrl = canvas.toDataURL('image/jpeg', quality);
|
| 58 |
+
// Remove prefix to get raw base64
|
| 59 |
+
const base64 = dataUrl.split(',')[1];
|
| 60 |
+
resolve(base64);
|
| 61 |
+
};
|
| 62 |
+
img.onerror = (err) => reject(err);
|
| 63 |
+
};
|
| 64 |
+
reader.onerror = (err) => reject(err);
|
| 65 |
+
});
|
| 66 |
+
};
|
| 67 |
+
|
| 68 |
const fileToBase64 = (file: File): Promise<string> => {
|
| 69 |
return new Promise((resolve, reject) => {
|
| 70 |
const reader = new FileReader();
|
|
|
|
| 204 |
const [orModels, setOrModels] = useState<OpenRouterModelConfig[]>([]);
|
| 205 |
const [newModelId, setNewModelId] = useState('');
|
| 206 |
const [newModelName, setNewModelName] = useState('');
|
| 207 |
+
const [newModelApiUrl, setNewModelApiUrl] = useState('');
|
| 208 |
|
| 209 |
// Audio Refs
|
| 210 |
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
|
|
| 556 |
if (selectedImages.length === 0) return;
|
| 557 |
setAssessmentStatus('UPLOADING');
|
| 558 |
try {
|
| 559 |
+
// Updated to use compressImage instead of fileToBase64
|
| 560 |
+
const base64Promises = selectedImages.map(file => compressImage(file));
|
| 561 |
const base64Images = await Promise.all(base64Promises);
|
| 562 |
handleAssessmentStreamingSubmit({ images: base64Images });
|
| 563 |
} catch(e) {
|
| 564 |
setAssessmentStatus('IDLE');
|
| 565 |
+
setToast({ show: true, message: '图片压缩上传失败', type: 'error' });
|
| 566 |
}
|
| 567 |
};
|
| 568 |
|
|
|
|
| 600 |
};
|
| 601 |
const handleAddModel = () => {
|
| 602 |
if (!newModelId.trim()) return;
|
| 603 |
+
// Include newModelApiUrl
|
| 604 |
+
setOrModels([...orModels, {
|
| 605 |
+
id: newModelId.trim(),
|
| 606 |
+
name: newModelName.trim() || newModelId.trim(),
|
| 607 |
+
apiUrl: newModelApiUrl.trim() || undefined,
|
| 608 |
+
isCustom: true
|
| 609 |
+
}]);
|
| 610 |
+
setNewModelId(''); setNewModelName(''); setNewModelApiUrl('');
|
| 611 |
};
|
| 612 |
const handleRemoveModel = (idx: number) => setOrModels(orModels.filter((_, i) => i !== idx));
|
| 613 |
const handleMoveModel = (idx: number, direction: -1 | 1) => {
|
|
|
|
| 629 |
|
| 630 |
// --- ADMIN VIEW ---
|
| 631 |
if (isAdmin) {
|
|
|
|
| 632 |
return (
|
| 633 |
<div className="p-6 md:p-10 max-w-5xl mx-auto space-y-8 animate-in fade-in pb-20 overflow-y-auto h-full">
|
| 634 |
{toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
|
|
|
|
| 693 |
<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>
|
| 694 |
</div>
|
| 695 |
<div>
|
| 696 |
+
<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>
|
| 697 |
+
<p className="text-xs text-gray-400 mb-3">备用线路。所有下方“大模型列表”中的模型都将使用这里的 Key。</p>
|
| 698 |
<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>
|
| 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>
|
| 705 |
+
<div className="flex flex-col md:flex-row gap-2 items-end bg-gray-50 p-3 rounded-lg border border-gray-200">
|
| 706 |
+
<div className="flex-1 w-full"><label className="text-xs text-gray-500 mb-1 block">模型 ID *</label><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="如: gpt-4o"/></div>
|
| 707 |
+
<div className="flex-1 w-full"><label className="text-xs text-gray-500 mb-1 block">显示名称</label><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="如: GPT-4o"/></div>
|
| 708 |
+
<div className="flex-[1.5] w-full"><label className="text-xs text-gray-500 mb-1 block">API URL (选填, 默认 OpenRouter)</label><input className="w-full border border-gray-300 rounded px-2 py-1.5 text-sm" value={newModelApiUrl} onChange={e=>setNewModelApiUrl(e.target.value)} placeholder="https://api.openai.com/v1"/></div>
|
| 709 |
+
<button onClick={handleAddModel} className="bg-indigo-600 text-white px-4 py-1.5 rounded text-sm hover:bg-indigo-700 h-9 w-full md:w-auto">添加</button>
|
| 710 |
+
</div>
|
| 711 |
</div>
|
| 712 |
</div>
|
| 713 |
</div>
|
|
|
|
| 716 |
|
| 717 |
if (!systemConfig?.enableAI) return <div className="flex flex-col items-center justify-center h-full text-center p-6 space-y-4"><div className="bg-gray-100 p-6 rounded-full"><Bot size={48} className="text-gray-400 grayscale"/></div><h2 className="text-xl font-bold text-gray-700">AI 助手维护中</h2><p className="text-gray-500 max-w-md">管理员已暂时关闭 AI 服务。</p></div>;
|
| 718 |
|
| 719 |
+
// ... (Main Render remains same as provided in previous file, utilizing new compressImage in confirmImageSubmission)
|
| 720 |
return (
|
| 721 |
<div className="h-full flex flex-col bg-slate-50 overflow-hidden relative">
|
| 722 |
{toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
|
|
|
|
| 802 |
<>
|
| 803 |
<ImageIcon className="mx-auto text-purple-300 mb-2" size={40}/>
|
| 804 |
<p className="text-purple-600 font-bold">点击上传作业图片</p>
|
| 805 |
+
<p className="text-xs text-gray-400">支持批量上传 • 自动压缩处理</p>
|
| 806 |
</>
|
| 807 |
) : (
|
| 808 |
<div className="z-0 w-full pointer-events-none opacity-50 flex items-center justify-center">
|
|
|
|
| 835 |
className="mt-6 w-full px-8 py-3 bg-purple-600 text-white rounded-lg font-bold hover:bg-purple-700 flex items-center justify-center gap-2 shadow-md transition-all"
|
| 836 |
>
|
| 837 |
{assessmentStatus !== 'IDLE' ? <Loader2 className="animate-spin" size={18}/> : <CheckCircle size={18}/>}
|
| 838 |
+
{assessmentStatus === 'UPLOADING' ? '压缩上传中...' : assessmentStatus === 'ANALYZING' ? 'AI 正在分析...' : assessmentStatus === 'TTS' ? '生成语音...' : `开始批改 (${selectedImages.length}张)`}
|
| 839 |
</button>
|
| 840 |
)}
|
| 841 |
</div>
|
|
|
|
| 890 |
)}
|
| 891 |
</div>
|
| 892 |
);
|
| 893 |
+
};
|
pages/AchievementTeacher.tsx
CHANGED
|
@@ -107,7 +107,7 @@ export const AchievementTeacher: React.FC<{className?: string}> = ({ className }
|
|
| 107 |
setToast({ show: true, message: '添加成功', type: 'success' });
|
| 108 |
};
|
| 109 |
|
| 110 |
-
//
|
| 111 |
const handleMoveItem = (item: AchievementItem, direction: number) => {
|
| 112 |
if (!config) return;
|
| 113 |
const allItems = [...config.achievements];
|
|
|
|
| 107 |
setToast({ show: true, message: '添加成功', type: 'success' });
|
| 108 |
};
|
| 109 |
|
| 110 |
+
// Manual Sort Logic (Swapping)
|
| 111 |
const handleMoveItem = (item: AchievementItem, direction: number) => {
|
| 112 |
if (!config) return;
|
| 113 |
const allItems = [...config.achievements];
|
types.ts
CHANGED
|
@@ -168,6 +168,7 @@ export interface Notification {
|
|
| 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 |
|
|
|
|
| 168 |
export interface OpenRouterModelConfig {
|
| 169 |
id: string; // The model name (e.g., qwen/qwen3-coder:free)
|
| 170 |
name?: string; // Optional friendly name
|
| 171 |
+
apiUrl?: string; // Optional custom API URL
|
| 172 |
isCustom: boolean; // Built-in vs Custom
|
| 173 |
}
|
| 174 |
|