dvc890 commited on
Commit
2bf9484
·
verified ·
1 Parent(s): 872bb54

Upload 58 files

Browse files
Files changed (5) hide show
  1. ai-routes.js +8 -2
  2. models.js +1 -0
  3. pages/AIAssistant.tsx +66 -15
  4. pages/AchievementTeacher.tsx +1 -1
  5. 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
- const base64Promises = selectedImages.map(file => fileToBase64(file));
 
520
  const base64Images = await Promise.all(base64Promises);
521
  handleAssessmentStreamingSubmit({ images: base64Images });
522
  } catch(e) {
523
  setAssessmentStatus('IDLE');
524
- setToast({ show: true, message: '图片上传失败', type: 'error' });
525
  }
526
  };
527
 
@@ -559,8 +600,14 @@ export const AIAssistant: React.FC = () => {
559
  };
560
  const handleAddModel = () => {
561
  if (!newModelId.trim()) return;
562
- setOrModels([...orModels, { id: newModelId.trim(), name: newModelName.trim() || newModelId.trim(), isCustom: true }]);
563
- setNewModelId(''); setNewModelName('');
 
 
 
 
 
 
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">备用线路。</p>
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="输入 OpenRouter 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>
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">OpenRouter 大模型列表管理</h4></div>
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></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>
659
- <div className="flex gap-2 items-end bg-gray-50 p-3 rounded-lg border border-gray-200"><div className="flex-1"><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="如: openai/gpt-4o"/></div><div className="flex-1"><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="名称"/></div><button onClick={handleAddModel} className="bg-indigo-600 text-white px-4 py-1.5 rounded text-sm hover:bg-indigo-700 h-9">添加</button></div>
 
 
 
 
 
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">支持批量上传 • 自动批改</p>
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' ? '上传中...' : assessmentStatus === 'ANALYZING' ? 'AI 正在分析...' : assessmentStatus === 'TTS' ? '生成语音...' : `开始批改 (${selectedImages.length}张)`}
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
- // New Manual Sort Logic (Swapping)
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