dvc890 commited on
Commit
03573ea
·
verified ·
1 Parent(s): 8a9e360

Upload 62 files

Browse files
ai-routes.js CHANGED
@@ -4,23 +4,7 @@ const router = express.Router();
4
  const OpenAI = require('openai');
5
  const { ConfigModel, User, AIUsageModel } = require('./models');
6
 
7
- // Fetch keys from DB + merge with ENV variables
8
- async function getKeyPool(type) {
9
- const config = await ConfigModel.findOne({ key: 'main' });
10
- const pool = [];
11
- if (config && config.apiKeys && config.apiKeys[type] && Array.isArray(config.apiKeys[type])) {
12
- config.apiKeys[type].forEach(k => { if (k && k.trim()) pool.push(k.trim()); });
13
- }
14
- if (type === 'gemini' && process.env.API_KEY && !pool.includes(process.env.API_KEY)) pool.push(process.env.API_KEY);
15
- if (type === 'openrouter' && process.env.OPENROUTER_API_KEY && !pool.includes(process.env.OPENROUTER_API_KEY)) pool.push(process.env.OPENROUTER_API_KEY);
16
- return pool;
17
- }
18
-
19
- async function getHFToken() {
20
- const config = await ConfigModel.findOne({ key: 'main' });
21
- return config ? config.hfToken : null;
22
- }
23
-
24
  async function recordUsage(model, provider) {
25
  try {
26
  const today = new Date().toISOString().split('T')[0];
@@ -31,7 +15,8 @@ async function recordUsage(model, provider) {
31
 
32
  // Fallback Speech-to-Text using Hugging Face (Fixed 410 Error)
33
  async function transcribeAudioWithHF(audioBase64) {
34
- const token = await getHFToken();
 
35
  if (!token) {
36
  console.warn("[AI] No Hugging Face token found for fallback STT.");
37
  return null;
@@ -41,7 +26,6 @@ async function transcribeAudioWithHF(audioBase64) {
41
  console.log("[AI] 🎤 Using Hugging Face ASR (Whisper v3)...");
42
  const buffer = Buffer.from(audioBase64, 'base64');
43
 
44
- // Using the standard inference endpoint which is more stable
45
  const response = await fetch(
46
  "https://api-inference.huggingface.co/models/openai/whisper-large-v3",
47
  {
@@ -123,37 +107,37 @@ function isQuotaError(e) {
123
 
124
  async function streamGemini(baseParams, res) {
125
  const { GoogleGenAI } = await import("@google/genai");
126
- const models = ['gemini-2.5-flash', 'gemini-2.5-flash-lite'];
127
- const keys = await getKeyPool('gemini');
128
- if (keys.length === 0) throw new Error("No Gemini API keys");
129
 
130
- for (const apiKey of keys) {
131
- const client = new GoogleGenAI({ apiKey });
132
- for (const modelName of models) {
133
- try {
134
- const result = await client.models.generateContentStream({ ...baseParams, model: modelName });
135
- let hasStarted = false;
136
- let fullText = "";
137
- for await (const chunk of result) {
138
- if (!hasStarted) { recordUsage(modelName, PROVIDERS.GEMINI); hasStarted = true; }
139
- if (chunk.text) {
140
- fullText += chunk.text;
141
- res.write(`data: ${JSON.stringify({ text: chunk.text })}\n\n`);
142
- }
143
- }
144
- return fullText;
145
- } catch (e) { if (isQuotaError(e)) continue; throw e; }
146
  }
 
 
 
147
  }
148
- throw new Error("Gemini exhausted");
149
  }
150
 
151
  async function streamOpenRouter(baseParams, res) {
152
  const config = await ConfigModel.findOne({ key: 'main' });
153
  const models = (config && config.openRouterModels?.length) ? config.openRouterModels.map(m => m.id) : DEFAULT_OPENROUTER_MODELS;
154
  let messages = convertGeminiToOpenAI(baseParams);
155
- const keys = await getKeyPool('openrouter');
156
- if (keys.length === 0) throw new Error("No OpenRouter API keys");
 
157
 
158
  for (let msg of messages) {
159
  if (Array.isArray(msg.content)) {
@@ -165,57 +149,55 @@ async function streamOpenRouter(baseParams, res) {
165
  part.text = `[语音转文字]: ${text}`;
166
  delete part.data;
167
  } else {
168
- throw new Error("语音转文字失败 (请检查 HF Token)");
169
  }
170
  }
171
  }
172
  }
173
  }
174
 
175
- for (const apiKey of keys) {
176
- for (const modelName of models) {
177
- const modelConfig = config?.openRouterModels?.find(m => m.id === modelName);
178
- const baseURL = modelConfig?.apiUrl ? modelConfig.apiUrl : "https://openrouter.ai/api/v1";
179
- const client = new OpenAI({ baseURL, apiKey });
180
- try {
181
- const stream = await client.chat.completions.create({ model: modelName, messages, stream: true });
182
- recordUsage(modelName, PROVIDERS.OPENROUTER);
183
- let fullText = '';
184
- for await (const chunk of stream) {
185
- const text = chunk.choices[0]?.delta?.content || '';
186
- if (text) {
187
- fullText += text;
188
- res.write(`data: ${JSON.stringify({ text: text })}\n\n`);
189
- }
190
  }
191
- return fullText;
192
- } catch (e) { if (isQuotaError(e)) break; }
193
- }
194
  }
195
- throw new Error("OpenRouter exhausted");
196
  }
197
 
198
  async function streamGemma(baseParams, res) {
199
  const { GoogleGenAI } = await import("@google/genai");
 
 
 
200
  const models = ['gemma-3-27b-it', 'gemma-3-12b-it'];
201
- const keys = await getKeyPool('gemini');
202
- for (const apiKey of keys) {
203
- const client = new GoogleGenAI({ apiKey });
204
- for (const modelName of models) {
205
- try {
206
- const result = await client.models.generateContentStream({ ...baseParams, model: modelName });
207
- let hasStarted = false;
208
- let fullText = "";
209
- for await (const chunk of result) {
210
- if (!hasStarted) { recordUsage(modelName, PROVIDERS.GEMMA); hasStarted = true; }
211
- if (chunk.text) {
212
- fullText += chunk.text;
213
- res.write(`data: ${JSON.stringify({ text: chunk.text })}\n\n`);
214
- }
215
  }
216
- return fullText;
217
- } catch (e) { if (isQuotaError(e)) continue; }
218
- }
219
  }
220
  throw new Error("Gemma failed");
221
  }
@@ -237,7 +219,7 @@ async function streamContentWithSmartFallback(baseParams, res) {
237
  continue;
238
  }
239
  }
240
- throw finalError || new Error('All failed');
241
  }
242
 
243
  const checkAIAccess = async (req, res, next) => {
@@ -254,9 +236,9 @@ const checkAIAccess = async (req, res, next) => {
254
 
255
  router.get('/live-access', checkAIAccess, async (req, res) => {
256
  try {
257
- const keys = await getKeyPool('gemini');
258
- if (keys.length === 0) return res.status(503).json({ error: 'No API keys available' });
259
- res.json({ key: keys[0] });
260
  } catch (e) { res.status(500).json({ error: e.message }); }
261
  });
262
 
@@ -305,22 +287,20 @@ router.post('/chat', checkAIAccess, async (req, res) => {
305
  if (answerText) {
306
  try {
307
  const { GoogleGenAI } = await import("@google/genai");
308
- const keys = await getKeyPool('gemini');
309
- let audioBytes = null;
310
- for (const apiKey of keys) {
311
- try {
312
- const client = new GoogleGenAI({ apiKey });
313
- const ttsResponse = await client.models.generateContent({
314
- model: "gemini-2.5-flash-preview-tts",
315
- contents: [{ parts: [{ text: answerText }] }],
316
- config: { responseModalities: ['AUDIO'], speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } } }
317
- });
318
- audioBytes = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
319
- if (audioBytes) break;
320
- } catch(e) { if (isQuotaError(e)) continue; break; }
321
  }
322
- if (audioBytes) res.write(`data: ${JSON.stringify({ audio: audioBytes })}\n\n`);
323
- else res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`);
324
  } catch (ttsError) { res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`); }
325
  }
326
  res.write('data: [DONE]\n\n'); res.end();
@@ -358,22 +338,20 @@ router.post('/evaluate', checkAIAccess, async (req, res) => {
358
  res.write(`data: ${JSON.stringify({ status: 'tts' })}\n\n`);
359
  try {
360
  const { GoogleGenAI } = await import("@google/genai");
361
- const keys = await getKeyPool('gemini');
362
- let feedbackAudio = null;
363
- for (const apiKey of keys) {
364
- try {
365
- const client = new GoogleGenAI({ apiKey });
366
- const ttsResponse = await client.models.generateContent({
367
- model: "gemini-2.5-flash-preview-tts",
368
- contents: [{ parts: [{ text: feedbackText }] }],
369
- config: { responseModalities: ['AUDIO'], speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } } }
370
- });
371
- feedbackAudio = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
372
- if (feedbackAudio) break;
373
- } catch(e) { if (isQuotaError(e)) continue; break; }
374
  }
375
- if (feedbackAudio) res.write(`data: ${JSON.stringify({ audio: feedbackAudio })}\n\n`);
376
- else res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`);
377
  } catch (ttsErr) { res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`); }
378
  }
379
  res.write('data: [DONE]\n\n'); res.end();
 
4
  const OpenAI = require('openai');
5
  const { ConfigModel, User, AIUsageModel } = require('./models');
6
 
7
+ // Fixed: Removed local database key pool and exclusively use process.env per guidelines
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  async function recordUsage(model, provider) {
9
  try {
10
  const today = new Date().toISOString().split('T')[0];
 
15
 
16
  // Fallback Speech-to-Text using Hugging Face (Fixed 410 Error)
17
  async function transcribeAudioWithHF(audioBase64) {
18
+ // Fixed: Obtain HF token from environment variable directly
19
+ const token = process.env.HF_TOKEN;
20
  if (!token) {
21
  console.warn("[AI] No Hugging Face token found for fallback STT.");
22
  return null;
 
26
  console.log("[AI] 🎤 Using Hugging Face ASR (Whisper v3)...");
27
  const buffer = Buffer.from(audioBase64, 'base64');
28
 
 
29
  const response = await fetch(
30
  "https://api-inference.huggingface.co/models/openai/whisper-large-v3",
31
  {
 
107
 
108
  async function streamGemini(baseParams, res) {
109
  const { GoogleGenAI } = await import("@google/genai");
110
+ // Fixed: Exclusively use process.env.API_KEY and gemini-3-flash-preview for text tasks
111
+ const apiKey = process.env.API_KEY;
112
+ if (!apiKey) throw new Error("API_KEY environment variable is not configured.");
113
 
114
+ const modelName = 'gemini-3-flash-preview';
115
+ const client = new GoogleGenAI({ apiKey: process.env.API_KEY });
116
+
117
+ try {
118
+ const result = await client.models.generateContentStream({ ...baseParams, model: modelName });
119
+ let hasStarted = false;
120
+ let fullText = "";
121
+ for await (const chunk of result) {
122
+ if (!hasStarted) { recordUsage(modelName, PROVIDERS.GEMINI); hasStarted = true; }
123
+ if (chunk.text) {
124
+ fullText += chunk.text;
125
+ res.write(`data: ${JSON.stringify({ text: chunk.text })}\n\n`);
126
+ }
 
 
 
127
  }
128
+ return fullText;
129
+ } catch (e) {
130
+ throw e;
131
  }
 
132
  }
133
 
134
  async function streamOpenRouter(baseParams, res) {
135
  const config = await ConfigModel.findOne({ key: 'main' });
136
  const models = (config && config.openRouterModels?.length) ? config.openRouterModels.map(m => m.id) : DEFAULT_OPENROUTER_MODELS;
137
  let messages = convertGeminiToOpenAI(baseParams);
138
+ // Use environment variable for OpenRouter key if available
139
+ const apiKey = process.env.OPENROUTER_API_KEY;
140
+ if (!apiKey) throw new Error("OPENROUTER_API_KEY environment variable is not configured.");
141
 
142
  for (let msg of messages) {
143
  if (Array.isArray(msg.content)) {
 
149
  part.text = `[语音转文字]: ${text}`;
150
  delete part.data;
151
  } else {
152
+ throw new Error("语音转文字失败 (请检查 HF_TOKEN)");
153
  }
154
  }
155
  }
156
  }
157
  }
158
 
159
+ for (const modelName of models) {
160
+ const modelConfig = config?.openRouterModels?.find(m => m.id === modelName);
161
+ const baseURL = modelConfig?.apiUrl ? modelConfig.apiUrl : "https://openrouter.ai/api/v1";
162
+ const client = new OpenAI({ baseURL, apiKey });
163
+ try {
164
+ const stream = await client.chat.completions.create({ model: modelName, messages, stream: true });
165
+ recordUsage(modelName, PROVIDERS.OPENROUTER);
166
+ let fullText = '';
167
+ for await (const chunk of stream) {
168
+ const text = chunk.choices[0]?.delta?.content || '';
169
+ if (text) {
170
+ fullText += text;
171
+ res.write(`data: ${JSON.stringify({ text: text })}\n\n`);
 
 
172
  }
173
+ }
174
+ return fullText;
175
+ } catch (e) { if (isQuotaError(e)) break; }
176
  }
177
+ throw new Error("OpenRouter exhausted or failed");
178
  }
179
 
180
  async function streamGemma(baseParams, res) {
181
  const { GoogleGenAI } = await import("@google/genai");
182
+ const apiKey = process.env.API_KEY;
183
+ if (!apiKey) throw new Error("API_KEY environment variable is not configured.");
184
+
185
  const models = ['gemma-3-27b-it', 'gemma-3-12b-it'];
186
+ const client = new GoogleGenAI({ apiKey: process.env.API_KEY });
187
+ for (const modelName of models) {
188
+ try {
189
+ const result = await client.models.generateContentStream({ ...baseParams, model: modelName });
190
+ let hasStarted = false;
191
+ let fullText = "";
192
+ for await (const chunk of result) {
193
+ if (!hasStarted) { recordUsage(modelName, PROVIDERS.GEMMA); hasStarted = true; }
194
+ if (chunk.text) {
195
+ fullText += chunk.text;
196
+ res.write(`data: ${JSON.stringify({ text: chunk.text })}\n\n`);
 
 
 
197
  }
198
+ }
199
+ return fullText;
200
+ } catch (e) { if (isQuotaError(e)) continue; }
201
  }
202
  throw new Error("Gemma failed");
203
  }
 
219
  continue;
220
  }
221
  }
222
+ throw finalError || new Error('All providers failed');
223
  }
224
 
225
  const checkAIAccess = async (req, res, next) => {
 
236
 
237
  router.get('/live-access', checkAIAccess, async (req, res) => {
238
  try {
239
+ const key = process.env.API_KEY;
240
+ if (!key) return res.status(503).json({ error: 'No API key available in environment' });
241
+ res.json({ key });
242
  } catch (e) { res.status(500).json({ error: e.message }); }
243
  });
244
 
 
287
  if (answerText) {
288
  try {
289
  const { GoogleGenAI } = await import("@google/genai");
290
+ const apiKey = process.env.API_KEY;
291
+ if (apiKey) {
292
+ const client = new GoogleGenAI({ apiKey: process.env.API_KEY });
293
+ const ttsResponse = await client.models.generateContent({
294
+ model: "gemini-2.5-flash-preview-tts",
295
+ contents: [{ parts: [{ text: answerText }] }],
296
+ config: { responseModalities: ['AUDIO'], speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } } }
297
+ });
298
+ const audioBytes = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
299
+ if (audioBytes) res.write(`data: ${JSON.stringify({ audio: audioBytes })}\n\n`);
300
+ else res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`);
301
+ } else {
302
+ res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`);
303
  }
 
 
304
  } catch (ttsError) { res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`); }
305
  }
306
  res.write('data: [DONE]\n\n'); res.end();
 
338
  res.write(`data: ${JSON.stringify({ status: 'tts' })}\n\n`);
339
  try {
340
  const { GoogleGenAI } = await import("@google/genai");
341
+ const apiKey = process.env.API_KEY;
342
+ if (apiKey) {
343
+ const client = new GoogleGenAI({ apiKey: process.env.API_KEY });
344
+ const ttsResponse = await client.models.generateContent({
345
+ model: "gemini-2.5-flash-preview-tts",
346
+ contents: [{ parts: [{ text: feedbackText }] }],
347
+ config: { responseModalities: ['AUDIO'], speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } } }
348
+ });
349
+ const feedbackAudio = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
350
+ if (feedbackAudio) res.write(`data: ${JSON.stringify({ audio: feedbackAudio })}\n\n`);
351
+ else res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`);
352
+ } else {
353
+ res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`);
354
  }
 
 
355
  } catch (ttsErr) { res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`); }
356
  }
357
  res.write('data: [DONE]\n\n'); res.end();
components/ai/AdminPanel.tsx CHANGED
@@ -28,12 +28,7 @@ export const AdminPanel: React.FC = () => {
28
  } | null>(null);
29
 
30
  // Key Management
31
- const [geminiKeys, setGeminiKeys] = useState<string[]>([]);
32
- const [openRouterKeys, setOpenRouterKeys] = useState<string[]>([]);
33
- const [hfToken, setHfToken] = useState('');
34
-
35
- const [newGeminiKey, setNewGeminiKey] = useState('');
36
- const [newOpenRouterKey, setNewOpenRouterKey] = useState('');
37
 
38
  // Model Management
39
  const [orModels, setOrModels] = useState<OpenRouterModelConfig[]>([]);
@@ -52,12 +47,6 @@ export const AdminPanel: React.FC = () => {
52
  try {
53
  const cfg = await api.config.get();
54
  setSystemConfig(cfg);
55
- if (cfg.apiKeys) {
56
- setGeminiKeys(cfg.apiKeys.gemini || []);
57
- setOpenRouterKeys(cfg.apiKeys.openrouter || []);
58
- }
59
- // @ts-ignore
60
- if (cfg.hfToken) setHfToken(cfg.hfToken);
61
 
62
  setOrModels(cfg.openRouterModels && cfg.openRouterModels.length > 0 ? cfg.openRouterModels : DEFAULT_OR_MODELS);
63
 
@@ -84,18 +73,6 @@ export const AdminPanel: React.FC = () => {
84
  }
85
  };
86
 
87
- const handleAddKey = (type: 'gemini' | 'openrouter') => {
88
- const key = type === 'gemini' ? newGeminiKey.trim() : newOpenRouterKey.trim();
89
- if (!key) return;
90
- if (type === 'gemini') { setGeminiKeys([...geminiKeys, key]); setNewGeminiKey(''); }
91
- else { setOpenRouterKeys([...openRouterKeys, key]); setNewOpenRouterKey(''); }
92
- };
93
-
94
- const removeKey = (type: 'gemini' | 'openrouter', index: number) => {
95
- if (type === 'gemini') setGeminiKeys(geminiKeys.filter((_, i) => i !== index));
96
- else setOpenRouterKeys(openRouterKeys.filter((_, i) => i !== index));
97
- };
98
-
99
  const handleAddModel = () => {
100
  if (!newModelId.trim()) return;
101
  setOrModels([...orModels, {
@@ -122,19 +99,16 @@ export const AdminPanel: React.FC = () => {
122
  setProviderOrder(newArr);
123
  };
124
 
125
- const saveApiKeys = async () => {
126
  if (!systemConfig) return;
127
  try {
128
  await api.config.save({
129
  ...systemConfig,
130
- apiKeys: { gemini: geminiKeys, openrouter: openRouterKeys },
131
- // @ts-ignore
132
- hfToken: hfToken,
133
  openRouterModels: orModels,
134
  aiProviderOrder: providerOrder
135
  });
136
  await api.ai.resetPool();
137
- setToast({ show: true, message: 'API 配置及模型列表已保存', type: 'success' });
138
  } catch (e) { setToast({ show: true, message: '保存失败', type: 'error' }); }
139
  };
140
 
@@ -147,7 +121,7 @@ export const AdminPanel: React.FC = () => {
147
  </div>
148
  <div>
149
  <h1 className="text-2xl font-bold text-gray-800">AI 智能助教管理后台</h1>
150
- <p className="text-gray-500">监控 AI 服务状态与用量,管理密钥池。</p>
151
  </div>
152
  </div>
153
 
@@ -192,57 +166,46 @@ export const AdminPanel: React.FC = () => {
192
  </div>
193
  </div>
194
  </div>
 
195
  <div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm">
196
- <div className="flex justify-between items-center mb-6"><h3 className="font-bold text-gray-800 flex items-center"><Key className="mr-2 text-amber-500"/> 多线路密钥池配置</h3><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"><Save size={16}/> 保存所有配置</button></div>
 
 
 
197
  <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
 
198
  <div>
199
- <div className="flex items-center justify-between mb-2"><label className="text-sm font-bold text-gray-700">Google Gemini / Gemma 密钥池</label><span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">{geminiKeys.length} 个</span></div>
200
- <p className="text-xs text-gray-400 mb-3">当一个 Key 额度耗尽时,系统将自动切换至下一个。</p>
201
- <div className="space-y-2 mb-3">{geminiKeys.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('gemini', idx)} className="text-gray-400 hover:text-red-500"><Trash2 size={14}/></button></div>))}</div>
202
- <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>
203
- </div>
204
- <div>
205
- <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>
206
- <p className="text-xs text-gray-400 mb-3">备用线路。所有下方“大模型列表”中的模型都将使用这里的 Key。</p>
207
- <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>
208
- <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>
209
-
210
- <div className="mt-6 border-t border-dashed border-gray-200 pt-4">
211
- <label className="text-sm font-bold text-gray-700 block mb-1">Hugging Face Token (Fallback STT)</label>
212
- <p className="text-xs text-gray-400 mb-2">用于在 OpenRouter 模式下,将语音转为文字 (Whisper v3)。</p>
213
- <input className="w-full border border-gray-300 rounded px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-purple-500" placeholder="hf_xxxxxxxx" value={hfToken} onChange={e => setHfToken(e.target.value)}/>
214
  </div>
215
- </div>
216
- </div>
217
-
218
- {/* Provider Order Management */}
219
- <div className="mt-8 border-t border-gray-100 pt-6">
220
- <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>
221
- <div className="bg-amber-50 p-4 rounded-lg border border-amber-100 mb-4">
222
- <p className="text-xs text-amber-800">系统将按照以下顺序尝试调用大模型。如果前一个服务商额度耗尽或报错,会自动切换到下一个。</p>
223
- </div>
224
- <div className="space-y-2 max-w-md">
225
- {providerOrder.map((provider, idx) => (
226
- <div key={provider} className="flex items-center gap-3 bg-white p-3 rounded-lg border border-gray-200 shadow-sm">
227
- <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>
228
- <div className="flex-1 font-bold text-gray-700">{provider}</div>
229
- <div className="flex gap-1">
230
- <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>
231
- <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>
232
  </div>
233
- </div>
234
- ))}
235
  </div>
236
- </div>
237
 
238
- <div className="mt-8 border-t border-gray-100 pt-6">
239
- <div className="flex justify-between items-center mb-4"><h4 className="font-bold text-gray-700 text-sm">OpenAI 格式大模型列表管理</h4></div>
240
- <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>
241
- <div className="flex flex-col md:flex-row gap-2 items-end bg-gray-50 p-3 rounded-lg border border-gray-200">
242
- <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>
243
- <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>
244
- <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>
245
- <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>
 
 
 
 
 
246
  </div>
247
  </div>
248
  </div>
 
28
  } | null>(null);
29
 
30
  // Key Management
31
+ // Fixed: Removed local key states to comply with guidelines prohibiting UI for entering keys
 
 
 
 
 
32
 
33
  // Model Management
34
  const [orModels, setOrModels] = useState<OpenRouterModelConfig[]>([]);
 
47
  try {
48
  const cfg = await api.config.get();
49
  setSystemConfig(cfg);
 
 
 
 
 
 
50
 
51
  setOrModels(cfg.openRouterModels && cfg.openRouterModels.length > 0 ? cfg.openRouterModels : DEFAULT_OR_MODELS);
52
 
 
73
  }
74
  };
75
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  const handleAddModel = () => {
77
  if (!newModelId.trim()) return;
78
  setOrModels([...orModels, {
 
99
  setProviderOrder(newArr);
100
  };
101
 
102
+ const saveSettings = async () => {
103
  if (!systemConfig) return;
104
  try {
105
  await api.config.save({
106
  ...systemConfig,
 
 
 
107
  openRouterModels: orModels,
108
  aiProviderOrder: providerOrder
109
  });
110
  await api.ai.resetPool();
111
+ setToast({ show: true, message: '模型列表及调用顺序已保存', type: 'success' });
112
  } catch (e) { setToast({ show: true, message: '保存失败', type: 'error' }); }
113
  };
114
 
 
121
  </div>
122
  <div>
123
  <h1 className="text-2xl font-bold text-gray-800">AI 智能助教管理后台</h1>
124
+ <p className="text-gray-500">监控 AI 服务状态与用量,管理模型调用逻辑。</p>
125
  </div>
126
  </div>
127
 
 
166
  </div>
167
  </div>
168
  </div>
169
+
170
  <div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm">
171
+ <div className="flex justify-between items-center mb-6"><h3 className="font-bold text-gray-800 flex items-center"><Key className="mr-2 text-amber-500"/> 模型线路与优先级配置</h3><button onClick={saveSettings} 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"><Save size={16}/> 保存模型配置</button></div>
172
+
173
+ {/* Fixed: Removed API Key Pool configuration section per guidelines */}
174
+
175
  <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
176
+ {/* Provider Order Management */}
177
  <div>
178
+ <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>
179
+ <div className="bg-amber-50 p-4 rounded-lg border border-amber-100 mb-4">
180
+ <p className="text-xs text-amber-800">系统将按序尝试调用。如果前一个服务商( Gemini)报错,将自动切换到下一个。Gemini API 密钥由环境变量配置。</p>
 
 
 
 
 
 
 
 
 
 
 
 
181
  </div>
182
+ <div className="space-y-2">
183
+ {providerOrder.map((provider, idx) => (
184
+ <div key={provider} className="flex items-center gap-3 bg-white p-3 rounded-lg border border-gray-200 shadow-sm">
185
+ <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>
186
+ <div className="flex-1 font-bold text-gray-700">{provider}</div>
187
+ <div className="flex gap-1">
188
+ <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>
189
+ <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>
190
+ </div>
 
 
 
 
 
 
 
 
191
  </div>
192
+ ))}
193
+ </div>
194
  </div>
 
195
 
196
+ <div>
197
+ <div className="flex justify-between items-center mb-4"><h4 className="font-bold text-gray-700 text-sm">OpenAI 格式大模型列表 (备用)</h4></div>
198
+ <div className="space-y-2 mb-4 bg-gray-50 p-3 rounded-lg border border-gray-200 h-[300px] overflow-y-auto custom-scrollbar">
199
+ {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>))}
200
+ </div>
201
+ <div className="flex flex-col gap-2 items-end bg-gray-50 p-3 rounded-lg border border-gray-200">
202
+ <div className="grid grid-cols-2 gap-2 w-full">
203
+ <div className="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>
204
+ <div className="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>
205
+ </div>
206
+ <div className="w-full"><label className="text-xs text-gray-500 mb-1 block">API URL (可选, 需环境变量支持对应的 KEY)</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>
207
+ <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>
208
+ </div>
209
  </div>
210
  </div>
211
  </div>
components/ai/AssessmentPanel.tsx CHANGED
@@ -26,6 +26,9 @@ export const AssessmentPanel: React.FC<AssessmentPanelProps> = ({ currentUser })
26
  const currentSourceRef = useRef<AudioBufferSourceNode | null>(null);
27
  const recognitionRef = useRef<any>(null);
28
 
 
 
 
29
  useEffect(() => {
30
  // @ts-ignore
31
  const AudioCtor = window.AudioContext || window.webkitAudioContext;
@@ -70,8 +73,12 @@ export const AssessmentPanel: React.FC<AssessmentPanelProps> = ({ currentUser })
70
  } catch (e) { console.error("Audio playback error", e); }
71
  };
72
 
73
- const startRecording = async () => {
 
74
  console.log("[Assessment] Starting Recording...");
 
 
 
75
  // @ts-ignore
76
  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
77
  if (SpeechRecognition) {
@@ -85,24 +92,31 @@ export const AssessmentPanel: React.FC<AssessmentPanelProps> = ({ currentUser })
85
  recognition.onstart = () => {
86
  setIsWebSpeechListening(true);
87
  setIsAssessmentRecording(true);
88
- setRecognizedText('');
89
  };
90
 
91
  recognition.onresult = (event: any) => {
92
- let final = '';
93
- for (let i = event.resultIndex; i < event.results.length; ++i) {
94
- if (event.results[i].isFinal) final += event.results[i][0].transcript;
95
  }
96
- if (final) setRecognizedText(prev => prev + final);
 
97
  };
98
 
99
  recognition.onerror = (e: any) => {
100
  console.warn("[Assessment] Web Speech Error:", e.error);
101
- stopRecording();
102
- if (e.error !== 'aborted') startAudioRecordingFallback();
 
 
 
 
103
  };
104
 
105
- recognition.onend = () => setIsWebSpeechListening(false);
 
 
 
106
  recognitionRef.current = recognition;
107
  recognition.start();
108
  return;
@@ -116,36 +130,56 @@ export const AssessmentPanel: React.FC<AssessmentPanelProps> = ({ currentUser })
116
  };
117
 
118
  const startAudioRecordingFallback = async () => {
 
119
  try {
120
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
121
  const mediaRecorder = new MediaRecorder(stream);
122
  mediaRecorderRef.current = mediaRecorder;
123
  audioChunksRef.current = [];
 
124
  mediaRecorder.ondataavailable = (event) => {
125
  if (event.data.size > 0) audioChunksRef.current.push(event.data);
126
  };
 
127
  mediaRecorder.onstop = async () => {
128
  const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
129
  const base64 = await blobToBase64(audioBlob);
130
  handleAssessmentStreamingSubmit({ audio: base64 });
131
  stream.getTracks().forEach(track => track.stop());
132
  };
 
133
  mediaRecorder.start();
134
  setIsAssessmentRecording(true);
135
  setIsWebSpeechListening(false);
136
  } catch (e) {
137
  setToast({ show: true, message: '无法访问麦克风', type: 'error' });
 
138
  }
139
  };
140
 
141
  const stopRecording = () => {
142
- setIsAssessmentRecording(false);
143
- if (isWebSpeechListening && recognitionRef.current) {
 
 
144
  recognitionRef.current.stop();
145
- setTimeout(() => { if (recognizedText) handleAssessmentStreamingSubmit({ text: recognizedText }); }, 500);
146
- } else if (mediaRecorderRef.current) {
 
 
 
 
 
 
 
 
 
147
  mediaRecorderRef.current.stop();
 
148
  }
 
 
 
149
  };
150
 
151
  const handleAssessmentStreamingSubmit = async ({ audio, images, text }: { audio?: string, images?: string[], text?: string }) => {
@@ -222,6 +256,24 @@ export const AssessmentPanel: React.FC<AssessmentPanelProps> = ({ currentUser })
222
  }
223
  };
224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  return (
226
  <div className="flex-1 p-6 overflow-y-auto h-full relative">
227
  {toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
@@ -237,38 +289,95 @@ export const AssessmentPanel: React.FC<AssessmentPanelProps> = ({ currentUser })
237
  </div>
238
  </h3>
239
  <textarea className="w-full bg-purple-50/50 border border-purple-100 rounded-xl p-4 text-gray-700 font-medium text-lg resize-none focus:ring-2 focus:ring-purple-200 outline-none" value={assessmentTopic} onChange={e => setAssessmentTopic(e.target.value)} rows={3}/>
 
240
  <div className="mt-6 flex justify-center">
241
  {assessmentMode === 'audio' ? (
242
- <button onMouseDown={startRecording} onMouseUp={stopRecording} onTouchStart={startRecording} onTouchEnd={stopRecording} disabled={assessmentStatus !== 'IDLE'} className={`px-8 py-4 rounded-full font-bold text-white flex items-center gap-3 shadow-lg transition-all ${isAssessmentRecording ? 'bg-red-500 scale-105' : 'bg-gradient-to-r from-purple-600 to-indigo-600 hover:shadow-purple-200 hover:scale-105 disabled:opacity-50'}`}>
 
 
 
 
243
  {assessmentStatus !== 'IDLE' ? <Loader2 className="animate-spin"/> : (isAssessmentRecording ? <StopCircle/> : <Mic/>)}
244
- {assessmentStatus === 'UPLOADING' ? '上传中...' : assessmentStatus === 'ANALYZING' ? 'AI 分析中...' : assessmentStatus === 'TTS' ? '生成语音...' : isAssessmentRecording ? '正在识别...' : '长按开始回答'}
245
  </button>
246
  ) : (
247
  <div className="w-full">
248
  <div className="relative border-2 border-dashed border-purple-200 rounded-xl p-4 text-center hover:bg-purple-50 transition-colors cursor-pointer min-h-[160px] flex flex-col items-center justify-center">
249
- <input type="file" accept="image/*" multiple className="absolute inset-0 opacity-0 cursor-pointer w-full h-full z-10" onChange={(e) => { if (e.target.files) setSelectedImages(prev => [...prev, ...Array.from(e.target.files!)]); }} onClick={(e) => (e.currentTarget.value = '')} />
250
- {selectedImages.length === 0 ? (<><ImageIcon className="mx-auto text-purple-300 mb-2" size={40}/><p className="text-purple-600 font-bold">点击上传作业图片</p></>) : (<div className="z-0 w-full pointer-events-none opacity-50 flex items-center justify-center"><Plus className="text-purple-300" size={40}/><span className="text-purple-400 font-bold ml-2">继续添加</span></div>)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  </div>
 
252
  {selectedImages.length > 0 && (
253
  <div className="mt-4 grid grid-cols-3 sm:grid-cols-4 gap-3 animate-in fade-in">
254
- {selectedImages.map((file, idx) => (<div key={idx} className="relative group aspect-square"><img src={URL.createObjectURL(file)} className="w-full h-full object-cover rounded-lg shadow-sm border border-gray-200" /><button onClick={() => setSelectedImages(prev => prev.filter((_, i) => i !== idx))} className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 shadow-md hover:bg-red-600 transition-colors z-20 scale-90 hover:scale-100"><X size={14}/></button></div>))}
255
- <button onClick={async () => { setAssessmentStatus('UPLOADING'); try { const images = await Promise.all(selectedImages.map(f => compressImage(f))); handleAssessmentStreamingSubmit({ images }); } catch(e) { setAssessmentStatus('IDLE'); } }} disabled={assessmentStatus !== 'IDLE'} 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">开始批改</button>
 
 
 
 
 
 
 
 
 
256
  </div>
257
  )}
 
 
 
 
 
 
 
 
 
 
 
258
  </div>
259
  )}
260
  </div>
261
  </div>
 
262
  {(streamedAssessment.transcription || streamedAssessment.feedback || streamedAssessment.score !== null) && (
263
  <div className="bg-white p-6 rounded-2xl border border-gray-200 shadow-lg animate-in slide-in-from-bottom-4">
264
  <div className="flex items-center justify-between border-b border-gray-100 pb-4 mb-4">
265
  <div className="flex items-center gap-2">
266
  <h3 className="font-bold text-xl text-gray-800">测评报告</h3>
267
- {assessmentStatus !== 'IDLE' && <div className="flex items-center gap-1 text-xs px-2 py-1 bg-purple-50 text-purple-600 rounded-full animate-pulse"><Zap size={12}/>正在处理...</div>}
 
 
 
 
 
268
  </div>
269
  <div className="flex items-center gap-4">
270
- {streamedAssessment.audio && <button onClick={() => playPCMAudio(streamedAssessment.audio!)} className="flex items-center gap-1 text-sm bg-purple-100 text-purple-700 px-3 py-1 rounded-full hover:bg-purple-200"><Volume2 size={16}/> 听AI点评</button>}
271
- {streamedAssessment.score !== null && <div className={`text-3xl font-black ${streamedAssessment.score >= 80 ? 'text-green-500' : streamedAssessment.score >= 60 ? 'text-yellow-500' : 'text-red-500'}`}>{streamedAssessment.score}<span className="text-sm text-gray-400 ml-1">分</span></div>}
 
 
 
 
 
 
 
 
272
  </div>
273
  </div>
274
  <div className="space-y-4">
@@ -278,7 +387,9 @@ export const AssessmentPanel: React.FC<AssessmentPanelProps> = ({ currentUser })
278
  </div>
279
  <div>
280
  <p className="text-xs font-bold text-gray-500 uppercase mb-2">AI 点评建议</p>
281
- <div className="p-4 bg-purple-50 text-purple-900 rounded-xl border border-purple-100 text-sm leading-relaxed whitespace-pre-wrap">{streamedAssessment.feedback || 'AI 思考中...'}</div>
 
 
282
  </div>
283
  </div>
284
  </div>
 
26
  const currentSourceRef = useRef<AudioBufferSourceNode | null>(null);
27
  const recognitionRef = useRef<any>(null);
28
 
29
+ // Store recognized text in a ref for faster/reliable access during stop callback
30
+ const textRef = useRef('');
31
+
32
  useEffect(() => {
33
  // @ts-ignore
34
  const AudioCtor = window.AudioContext || window.webkitAudioContext;
 
73
  } catch (e) { console.error("Audio playback error", e); }
74
  };
75
 
76
+ const startRecording = async (e?: React.MouseEvent | React.TouchEvent) => {
77
+ if (e) { e.preventDefault(); e.stopPropagation(); }
78
  console.log("[Assessment] Starting Recording...");
79
+ textRef.current = '';
80
+ setRecognizedText('');
81
+
82
  // @ts-ignore
83
  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
84
  if (SpeechRecognition) {
 
92
  recognition.onstart = () => {
93
  setIsWebSpeechListening(true);
94
  setIsAssessmentRecording(true);
 
95
  };
96
 
97
  recognition.onresult = (event: any) => {
98
+ let full = '';
99
+ for (let i = 0; i < event.results.length; ++i) {
100
+ full += event.results[i][0].transcript;
101
  }
102
+ textRef.current = full;
103
+ setRecognizedText(full);
104
  };
105
 
106
  recognition.onerror = (e: any) => {
107
  console.warn("[Assessment] Web Speech Error:", e.error);
108
+ if (e.error !== 'aborted') {
109
+ startAudioRecordingFallback();
110
+ } else {
111
+ setIsAssessmentRecording(false);
112
+ setIsWebSpeechListening(false);
113
+ }
114
  };
115
 
116
+ recognition.onend = () => {
117
+ setIsWebSpeechListening(false);
118
+ };
119
+
120
  recognitionRef.current = recognition;
121
  recognition.start();
122
  return;
 
130
  };
131
 
132
  const startAudioRecordingFallback = async () => {
133
+ console.log("[Assessment] Falling back to MediaRecorder");
134
  try {
135
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
136
  const mediaRecorder = new MediaRecorder(stream);
137
  mediaRecorderRef.current = mediaRecorder;
138
  audioChunksRef.current = [];
139
+
140
  mediaRecorder.ondataavailable = (event) => {
141
  if (event.data.size > 0) audioChunksRef.current.push(event.data);
142
  };
143
+
144
  mediaRecorder.onstop = async () => {
145
  const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
146
  const base64 = await blobToBase64(audioBlob);
147
  handleAssessmentStreamingSubmit({ audio: base64 });
148
  stream.getTracks().forEach(track => track.stop());
149
  };
150
+
151
  mediaRecorder.start();
152
  setIsAssessmentRecording(true);
153
  setIsWebSpeechListening(false);
154
  } catch (e) {
155
  setToast({ show: true, message: '无法访问麦克风', type: 'error' });
156
+ setIsAssessmentRecording(false);
157
  }
158
  };
159
 
160
  const stopRecording = () => {
161
+ console.log("[Assessment] Stop recording requested");
162
+ const wasWebSpeech = isWebSpeechListening;
163
+
164
+ if (wasWebSpeech && recognitionRef.current) {
165
  recognitionRef.current.stop();
166
+ // Use current textRef because state might be stale
167
+ const finalSpeechText = textRef.current;
168
+ setTimeout(() => {
169
+ if (finalSpeechText.trim()) {
170
+ handleAssessmentStreamingSubmit({ text: finalSpeechText });
171
+ } else {
172
+ // Try fallback if no text captured but button was held
173
+ setToast({ show: true, message: '未检测到有效语音内容', type: 'error' });
174
+ }
175
+ }, 300);
176
+ } else if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
177
  mediaRecorderRef.current.stop();
178
+ // handleAssessmentStreamingSubmit will be called by onstop
179
  }
180
+
181
+ setIsAssessmentRecording(false);
182
+ setIsWebSpeechListening(false);
183
  };
184
 
185
  const handleAssessmentStreamingSubmit = async ({ audio, images, text }: { audio?: string, images?: string[], text?: string }) => {
 
256
  }
257
  };
258
 
259
+ const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
260
+ if (e.target.files && e.target.files.length > 0) {
261
+ setSelectedImages(prev => [...prev, ...Array.from(e.target.files!)]);
262
+ }
263
+ };
264
+
265
+ const confirmImageSubmission = async () => {
266
+ if (selectedImages.length === 0) return;
267
+ setAssessmentStatus('UPLOADING');
268
+ try {
269
+ const images = await Promise.all(selectedImages.map(f => compressImage(f)));
270
+ handleAssessmentStreamingSubmit({ images });
271
+ } catch(e) {
272
+ setAssessmentStatus('IDLE');
273
+ setToast({ show: true, message: '图片压缩上传失败', type: 'error' });
274
+ }
275
+ };
276
+
277
  return (
278
  <div className="flex-1 p-6 overflow-y-auto h-full relative">
279
  {toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
 
289
  </div>
290
  </h3>
291
  <textarea className="w-full bg-purple-50/50 border border-purple-100 rounded-xl p-4 text-gray-700 font-medium text-lg resize-none focus:ring-2 focus:ring-purple-200 outline-none" value={assessmentTopic} onChange={e => setAssessmentTopic(e.target.value)} rows={3}/>
292
+
293
  <div className="mt-6 flex justify-center">
294
  {assessmentMode === 'audio' ? (
295
+ <button
296
+ onMouseDown={startRecording} onMouseUp={stopRecording} onTouchStart={startRecording} onTouchEnd={stopRecording}
297
+ disabled={assessmentStatus !== 'IDLE'}
298
+ className={`px-8 py-4 rounded-full font-bold text-white flex items-center gap-3 shadow-lg transition-all ${isAssessmentRecording ? 'bg-red-500 scale-105' : 'bg-gradient-to-r from-purple-600 to-indigo-600 hover:shadow-purple-200 hover:scale-105 disabled:opacity-50'}`}
299
+ >
300
  {assessmentStatus !== 'IDLE' ? <Loader2 className="animate-spin"/> : (isAssessmentRecording ? <StopCircle/> : <Mic/>)}
301
+ {assessmentStatus === 'UPLOADING' ? '上传中...' : assessmentStatus === 'ANALYZING' ? 'AI 正在分析...' : assessmentStatus === 'TTS' ? '生成语音...' : isAssessmentRecording ? (isWebSpeechListening ? '正在识别...' : '松开结束录音') : '按住开始回答'}
302
  </button>
303
  ) : (
304
  <div className="w-full">
305
  <div className="relative border-2 border-dashed border-purple-200 rounded-xl p-4 text-center hover:bg-purple-50 transition-colors cursor-pointer min-h-[160px] flex flex-col items-center justify-center">
306
+ <input
307
+ type="file"
308
+ accept="image/*"
309
+ multiple
310
+ className="absolute inset-0 opacity-0 cursor-pointer w-full h-full z-10"
311
+ onChange={handleImageUpload}
312
+ onClick={(e) => (e.currentTarget.value = '')}
313
+ />
314
+ {selectedImages.length === 0 ? (
315
+ <>
316
+ <ImageIcon className="mx-auto text-purple-300 mb-2" size={40}/>
317
+ <p className="text-purple-600 font-bold">点击上传作业图片</p>
318
+ </>
319
+ ) : (
320
+ <div className="z-0 w-full pointer-events-none opacity-50 flex items-center justify-center">
321
+ <Plus className="text-purple-300" size={40}/>
322
+ <span className="text-purple-400 font-bold ml-2">继续添加图片</span>
323
+ </div>
324
+ )}
325
  </div>
326
+
327
  {selectedImages.length > 0 && (
328
  <div className="mt-4 grid grid-cols-3 sm:grid-cols-4 gap-3 animate-in fade-in">
329
+ {selectedImages.map((file, idx) => (
330
+ <div key={idx} className="relative group aspect-square">
331
+ <img src={URL.createObjectURL(file)} className="w-full h-full object-cover rounded-lg shadow-sm border border-gray-200" />
332
+ <button
333
+ onClick={() => setSelectedImages(prev => prev.filter((_, i) => i !== idx))}
334
+ className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 shadow-md hover:bg-red-600 transition-colors z-20 scale-90 hover:scale-100"
335
+ >
336
+ <X size={14}/>
337
+ </button>
338
+ </div>
339
+ ))}
340
  </div>
341
  )}
342
+
343
+ {selectedImages.length > 0 && (
344
+ <button
345
+ onClick={confirmImageSubmission}
346
+ disabled={assessmentStatus !== 'IDLE'}
347
+ 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"
348
+ >
349
+ {assessmentStatus !== 'IDLE' ? <Loader2 className="animate-spin" size={18}/> : <CheckCircle size={18}/>}
350
+ {assessmentStatus === 'UPLOADING' ? '压缩上传中...' : assessmentStatus === 'ANALYZING' ? 'AI 分析中...' : assessmentStatus === 'TTS' ? '生成语音...' : `开始批改 (${selectedImages.length}张)`}
351
+ </button>
352
+ )}
353
  </div>
354
  )}
355
  </div>
356
  </div>
357
+
358
  {(streamedAssessment.transcription || streamedAssessment.feedback || streamedAssessment.score !== null) && (
359
  <div className="bg-white p-6 rounded-2xl border border-gray-200 shadow-lg animate-in slide-in-from-bottom-4">
360
  <div className="flex items-center justify-between border-b border-gray-100 pb-4 mb-4">
361
  <div className="flex items-center gap-2">
362
  <h3 className="font-bold text-xl text-gray-800">测评报告</h3>
363
+ {assessmentStatus !== 'IDLE' && (
364
+ <div className="flex items-center gap-1 text-xs px-2 py-1 bg-purple-50 text-purple-600 rounded-full animate-pulse">
365
+ <Zap size={12}/>
366
+ 正在处理中...
367
+ </div>
368
+ )}
369
  </div>
370
  <div className="flex items-center gap-4">
371
+ {streamedAssessment.audio && (
372
+ <button onClick={() => playPCMAudio(streamedAssessment.audio!)} className="flex items-center gap-1 text-sm bg-purple-100 text-purple-700 px-3 py-1 rounded-full hover:bg-purple-200">
373
+ <Volume2 size={16}/> 听AI点评
374
+ </button>
375
+ )}
376
+ {streamedAssessment.score !== null && (
377
+ <div className={`text-3xl font-black ${streamedAssessment.score >= 80 ? 'text-green-500' : streamedAssessment.score >= 60 ? 'text-yellow-500' : 'text-red-500'}`}>
378
+ {streamedAssessment.score}<span className="text-sm text-gray-400 ml-1">分</span>
379
+ </div>
380
+ )}
381
  </div>
382
  </div>
383
  <div className="space-y-4">
 
387
  </div>
388
  <div>
389
  <p className="text-xs font-bold text-gray-500 uppercase mb-2">AI 点评建议</p>
390
+ <div className="p-4 bg-purple-50 text-purple-900 rounded-xl border border-purple-100 text-sm leading-relaxed whitespace-pre-wrap">
391
+ {streamedAssessment.feedback || 'AI 思考中...'}
392
+ </div>
393
  </div>
394
  </div>
395
  </div>
components/ai/ChatPanel.tsx CHANGED
@@ -38,8 +38,12 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
38
  const currentSourceRef = useRef<AudioBufferSourceNode | null>(null);
39
  const messagesEndRef = useRef<HTMLDivElement>(null);
40
  const recognitionRef = useRef<any>(null);
 
41
  const inputRef = useRef<HTMLInputElement>(null);
42
 
 
 
 
43
  useEffect(() => {
44
  // @ts-ignore
45
  const AudioCtor = window.AudioContext || window.webkitAudioContext;
@@ -94,12 +98,12 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
94
  };
95
 
96
  const startRecording = async (e?: React.MouseEvent | React.TouchEvent) => {
97
- if (e) {
98
- e.preventDefault();
99
- e.stopPropagation();
100
- }
101
 
102
- console.log("[Voice] Starting...");
103
  // @ts-ignore
104
  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
105
 
@@ -113,19 +117,30 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
113
  recognition.continuous = true;
114
 
115
  recognition.onstart = () => {
116
- console.log("[Voice] Web Speech Active");
117
  setIsWebSpeechListening(true);
118
  setIsChatRecording(true);
119
  };
120
 
121
  recognition.onresult = (event: any) => {
122
- let transcript = '';
 
 
123
  for (let i = event.resultIndex; i < event.results.length; ++i) {
124
- transcript += event.results[i][0].transcript;
 
 
 
 
 
125
  }
126
- if (transcript) {
127
- setTextInput(transcript);
 
 
128
  }
 
 
 
129
  };
130
 
131
  recognition.onerror = (e: any) => {
@@ -137,7 +152,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
137
  };
138
 
139
  recognition.onend = () => {
140
- console.log("[Voice] Web Speech Stopped");
141
  setIsWebSpeechListening(false);
142
  setIsChatRecording(false);
143
  };
@@ -146,7 +160,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
146
  recognition.start();
147
  return;
148
  } catch (e) {
149
- console.error("[Voice] Web Speech Failed, falling back", e);
150
  startAudioRecordingFallback();
151
  }
152
  } else {
@@ -299,10 +313,8 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
299
  <div ref={messagesEndRef} />
300
  </div>
301
 
302
- {/* Unified Input Bar (No switching, Mic and Text coexist) */}
303
  <div className="p-4 bg-white border-t border-gray-200 shrink-0 z-20">
304
  <div className="flex items-center gap-3 max-w-4xl mx-auto bg-gray-50 p-2 rounded-2xl border border-gray-200">
305
- {/* Recording Status Dot */}
306
  {isChatRecording && (
307
  <div className="flex items-center gap-1.5 px-3 py-1 bg-red-100 text-red-600 rounded-full animate-pulse text-[10px] font-bold shrink-0">
308
  <div className="w-2 h-2 bg-red-600 rounded-full"></div>
@@ -321,7 +333,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
321
  />
322
 
323
  <div className="flex items-center gap-2 shrink-0">
324
- {/* Mic Button - Coexists with input */}
325
  <button
326
  onMouseDown={startRecording}
327
  onMouseUp={stopRecording}
@@ -333,7 +344,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
333
  {isChatRecording ? <StopCircle size={22}/> : <Mic size={22}/>}
334
  </button>
335
 
336
- {/* Send Button */}
337
  <button
338
  onClick={() => handleChatSubmit(textInput)}
339
  className={`p-3 rounded-xl transition-all ${!textInput.trim() || isChatProcessing ? 'bg-gray-100 text-gray-300' : 'bg-blue-600 text-white hover:bg-blue-700 shadow-md'}`}
 
38
  const currentSourceRef = useRef<AudioBufferSourceNode | null>(null);
39
  const messagesEndRef = useRef<HTMLDivElement>(null);
40
  const recognitionRef = useRef<any>(null);
41
+ // Fixed: Added missing inputRef to fix the error in line 324
42
  const inputRef = useRef<HTMLInputElement>(null);
43
 
44
+ // Track the text that was already in the box when we started speaking
45
+ const baseTextRef = useRef('');
46
+
47
  useEffect(() => {
48
  // @ts-ignore
49
  const AudioCtor = window.AudioContext || window.webkitAudioContext;
 
98
  };
99
 
100
  const startRecording = async (e?: React.MouseEvent | React.TouchEvent) => {
101
+ if (e) { e.preventDefault(); e.stopPropagation(); }
102
+
103
+ // Save what's already in the input so we can append to it
104
+ baseTextRef.current = textInput;
105
 
106
+ console.log("[Voice] Start listening...");
107
  // @ts-ignore
108
  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
109
 
 
117
  recognition.continuous = true;
118
 
119
  recognition.onstart = () => {
 
120
  setIsWebSpeechListening(true);
121
  setIsChatRecording(true);
122
  };
123
 
124
  recognition.onresult = (event: any) => {
125
+ let interimTranscript = '';
126
+ let finalTranscript = '';
127
+
128
  for (let i = event.resultIndex; i < event.results.length; ++i) {
129
+ const transcript = event.results[i][0].transcript;
130
+ if (event.results[i].isFinal) {
131
+ finalTranscript += transcript;
132
+ } else {
133
+ interimTranscript += transcript;
134
+ }
135
  }
136
+
137
+ // Always append newly finalized text to the base
138
+ if (finalTranscript) {
139
+ baseTextRef.current += finalTranscript;
140
  }
141
+
142
+ // Update input box with: Old text + currently recognized final chunks + currently recognized interim chunks
143
+ setTextInput(baseTextRef.current + interimTranscript);
144
  };
145
 
146
  recognition.onerror = (e: any) => {
 
152
  };
153
 
154
  recognition.onend = () => {
 
155
  setIsWebSpeechListening(false);
156
  setIsChatRecording(false);
157
  };
 
160
  recognition.start();
161
  return;
162
  } catch (e) {
163
+ console.error("[Voice] Web Speech Init Exception", e);
164
  startAudioRecordingFallback();
165
  }
166
  } else {
 
313
  <div ref={messagesEndRef} />
314
  </div>
315
 
 
316
  <div className="p-4 bg-white border-t border-gray-200 shrink-0 z-20">
317
  <div className="flex items-center gap-3 max-w-4xl mx-auto bg-gray-50 p-2 rounded-2xl border border-gray-200">
 
318
  {isChatRecording && (
319
  <div className="flex items-center gap-1.5 px-3 py-1 bg-red-100 text-red-600 rounded-full animate-pulse text-[10px] font-bold shrink-0">
320
  <div className="w-2 h-2 bg-red-600 rounded-full"></div>
 
333
  />
334
 
335
  <div className="flex items-center gap-2 shrink-0">
 
336
  <button
337
  onMouseDown={startRecording}
338
  onMouseUp={stopRecording}
 
344
  {isChatRecording ? <StopCircle size={22}/> : <Mic size={22}/>}
345
  </button>
346
 
 
347
  <button
348
  onClick={() => handleChatSubmit(textInput)}
349
  className={`p-3 rounded-xl transition-all ${!textInput.trim() || isChatProcessing ? 'bg-gray-100 text-gray-300' : 'bg-blue-600 text-white hover:bg-blue-700 shadow-md'}`}
server.js CHANGED
@@ -73,22 +73,19 @@ wss.on('connection', async (ws, req) => {
73
  let isGeminiConnected = false;
74
 
75
  try {
76
- // 1. Get API Key (Server-side Config)
77
- const config = await ConfigModel.findOne({ key: 'main' });
78
- let apiKey = process.env.API_KEY;
79
- if (config && config.apiKeys && config.apiKeys.gemini && config.apiKeys.gemini.length > 0) {
80
- apiKey = config.apiKeys.gemini[0];
81
- }
82
 
83
  if (!apiKey) {
84
- ws.send(JSON.stringify({ type: 'error', message: 'No Server API Key Configured' }));
 
85
  ws.close();
86
  return;
87
  }
88
 
89
  // 2. Initialize Gemini SDK
90
  const { GoogleGenAI } = await import("@google/genai");
91
- const client = new GoogleGenAI({ apiKey });
92
 
93
  // 3. Connect to Gemini (Isolated Session per Connection)
94
  geminiSession = await client.live.connect({
 
73
  let isGeminiConnected = false;
74
 
75
  try {
76
+ // 1. Fixed: Obtain API key exclusively from environment variable as per guidelines
77
+ const apiKey = process.env.API_KEY;
 
 
 
 
78
 
79
  if (!apiKey) {
80
+ console.error('[Live] Error: API_KEY environment variable is not configured.');
81
+ ws.send(JSON.stringify({ type: 'error', message: 'Server API Key is missing in environment' }));
82
  ws.close();
83
  return;
84
  }
85
 
86
  // 2. Initialize Gemini SDK
87
  const { GoogleGenAI } = await import("@google/genai");
88
+ const client = new GoogleGenAI({ apiKey: process.env.API_KEY });
89
 
90
  // 3. Connect to Gemini (Isolated Session per Connection)
91
  geminiSession = await client.live.connect({