Spaces:
Sleeping
Sleeping
Upload 63 files
Browse files- ai-routes.js +172 -23
- components/ai/AdminPanel.tsx +36 -19
- models.js +4 -2
- types.ts +3 -2
ai-routes.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
const express = require('express');
|
| 3 |
const router = express.Router();
|
| 4 |
const OpenAI = require('openai');
|
|
|
|
| 5 |
const { ConfigModel, User, AIUsageModel, ChatHistoryModel } = require('./models');
|
| 6 |
const { buildUserContext } = require('./ai-context');
|
| 7 |
|
|
@@ -71,8 +72,9 @@ function convertGeminiToOpenAI(baseParams) {
|
|
| 71 |
return messages;
|
| 72 |
}
|
| 73 |
|
| 74 |
-
const PROVIDERS = { GEMINI: 'GEMINI', OPENROUTER: 'OPENROUTER', GEMMA: 'GEMMA' };
|
| 75 |
const DEFAULT_OPENROUTER_MODELS = ['qwen/qwen3-coder:free', 'openai/gpt-oss-120b:free', 'qwen/qwen3-235b-a22b:free', 'tngtech/deepseek-r1t-chimera:free'];
|
|
|
|
| 76 |
|
| 77 |
// Runtime override logic
|
| 78 |
let runtimeProviderOrder = [];
|
|
@@ -86,7 +88,8 @@ function deprioritizeProvider(providerName) {
|
|
| 86 |
|
| 87 |
function isQuotaError(e) {
|
| 88 |
const msg = (e.message || '').toLowerCase();
|
| 89 |
-
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
// Streaming Helpers
|
|
@@ -132,6 +135,160 @@ async function streamGemini(baseParams, res) {
|
|
| 132 |
throw new Error("Gemini streaming failed (All keys/models exhausted)");
|
| 133 |
}
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
async function streamOpenRouter(baseParams, res) {
|
| 136 |
const config = await ConfigModel.findOne({ key: 'main' });
|
| 137 |
const models = (config && config.openRouterModels?.length) ? config.openRouterModels.map(m => m.id) : DEFAULT_OPENROUTER_MODELS;
|
|
@@ -151,16 +308,12 @@ async function streamOpenRouter(baseParams, res) {
|
|
| 151 |
|
| 152 |
const client = new OpenAI({ baseURL, apiKey, defaultHeaders: { "HTTP-Referer": "https://smart.com", "X-Title": "Smart School" } });
|
| 153 |
|
| 154 |
-
// --- DOUBAO OPTIMIZATION (Context Caching) ---
|
| 155 |
const extraBody = {};
|
|
|
|
| 156 |
if (modelName.toLowerCase().includes('doubao')) {
|
| 157 |
-
console.log(`[AI] 💡 Activating Doubao Prefix Caching for ${modelName}`);
|
| 158 |
-
// Doubao-specific caching parameter
|
| 159 |
extraBody.caching = { type: "enabled", prefix: true };
|
| 160 |
-
// Disable thinking to save tokens/time if not needed (optional based on user pref, but here we prioritize speed for chat)
|
| 161 |
extraBody.thinking = { type: "disabled" };
|
| 162 |
}
|
| 163 |
-
// ---------------------------------------------
|
| 164 |
|
| 165 |
try {
|
| 166 |
console.log(`[AI] 🚀 Attempting ${providerLabel} Model: ${modelName} (URL: ${baseURL})`);
|
|
@@ -234,7 +387,7 @@ async function streamGemma(baseParams, res) {
|
|
| 234 |
throw new Error("Gemma stream failed");
|
| 235 |
}
|
| 236 |
|
| 237 |
-
async function streamContentWithSmartFallback(baseParams, res) {
|
| 238 |
let hasAudio = false;
|
| 239 |
const contentsArray = Array.isArray(baseParams.contents) ? baseParams.contents : [baseParams.contents];
|
| 240 |
|
|
@@ -258,7 +411,7 @@ async function streamContentWithSmartFallback(baseParams, res) {
|
|
| 258 |
const config = await ConfigModel.findOne({ key: 'main' });
|
| 259 |
const configuredOrder = config?.aiProviderOrder && config.aiProviderOrder.length > 0
|
| 260 |
? config.aiProviderOrder
|
| 261 |
-
: [PROVIDERS.GEMINI, PROVIDERS.OPENROUTER, PROVIDERS.GEMMA];
|
| 262 |
|
| 263 |
const runtimeSet = new Set(runtimeProviderOrder);
|
| 264 |
if (runtimeProviderOrder.length === 0 || runtimeProviderOrder.length !== configuredOrder.length || !configuredOrder.every(p => runtimeSet.has(p))) {
|
|
@@ -268,9 +421,10 @@ async function streamContentWithSmartFallback(baseParams, res) {
|
|
| 268 |
let finalError = null;
|
| 269 |
for (const provider of runtimeProviderOrder) {
|
| 270 |
try {
|
| 271 |
-
console.log(`[AI] 👉 Trying Provider: ${provider}
|
| 272 |
if (provider === PROVIDERS.GEMINI) return await streamGemini(baseParams, res);
|
| 273 |
else if (provider === PROVIDERS.OPENROUTER) return await streamOpenRouter(baseParams, res);
|
|
|
|
| 274 |
else if (provider === PROVIDERS.GEMMA) return await streamGemma(baseParams, res);
|
| 275 |
} catch (e) {
|
| 276 |
console.error(`[AI] ❌ Provider ${provider} Failed: ${e.message}`);
|
|
@@ -368,20 +522,9 @@ router.post('/chat', checkAIAccess, async (req, res) => {
|
|
| 368 |
}));
|
| 369 |
|
| 370 |
// 3. PREPARE REQUEST
|
| 371 |
-
// The last user message is already in DB and retrieved in historyContext.
|
| 372 |
-
// We need to separate "history" from "current message" for some APIs,
|
| 373 |
-
// but Google/OpenAI handle a list of messages fine.
|
| 374 |
-
// However, standard pattern is: History + Current.
|
| 375 |
-
// Since we fetched ALL (including current), we just pass historyContext as contents.
|
| 376 |
-
// NOTE: If audio is present, we must append it specifically as the "current" part
|
| 377 |
-
// because DB only stores text representation for now.
|
| 378 |
-
|
| 379 |
const fullContents = [...historyContext];
|
| 380 |
|
| 381 |
-
// If this request has audio, append it as a new part (since DB load only has text placeholder)
|
| 382 |
-
// We replace the last 'user' text message with the audio payload for the AI model
|
| 383 |
if (audio) {
|
| 384 |
-
// Remove the text placeholder we just loaded
|
| 385 |
if (fullContents.length > 0 && fullContents[fullContents.length - 1].role === 'user') {
|
| 386 |
fullContents.pop();
|
| 387 |
}
|
|
@@ -397,10 +540,11 @@ router.post('/chat', checkAIAccess, async (req, res) => {
|
|
| 397 |
const combinedSystemInstruction = `${baseSystemInstruction}\n${contextPrompt}`;
|
| 398 |
// ---------------------------
|
| 399 |
|
|
|
|
| 400 |
const answerText = await streamContentWithSmartFallback({
|
| 401 |
contents: fullContents,
|
| 402 |
config: { systemInstruction: combinedSystemInstruction }
|
| 403 |
-
}, res);
|
| 404 |
|
| 405 |
// 4. SAVE AI RESPONSE TO DB
|
| 406 |
if (answerText) {
|
|
@@ -438,6 +582,11 @@ router.post('/chat', checkAIAccess, async (req, res) => {
|
|
| 438 |
// STREAMING ASSESSMENT ENDPOINT
|
| 439 |
router.post('/evaluate', checkAIAccess, async (req, res) => {
|
| 440 |
const { question, audio, image, images } = req.body;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
res.setHeader('Content-Type', 'text/event-stream');
|
| 442 |
res.setHeader('Cache-Control', 'no-cache');
|
| 443 |
res.setHeader('Connection', 'keep-alive');
|
|
@@ -482,7 +631,7 @@ router.post('/evaluate', checkAIAccess, async (req, res) => {
|
|
| 482 |
// CRITICAL FIX: Pass as array of objects for OpenRouter compatibility
|
| 483 |
contents: [{ role: 'user', parts: evalParts }],
|
| 484 |
// NO JSON MODE to allow progressive text streaming
|
| 485 |
-
}, res);
|
| 486 |
|
| 487 |
// Extract Feedback for TTS
|
| 488 |
const feedbackMatch = fullText.match(/## Feedback\s+([\s\S]*?)(?=## Score|$)/i);
|
|
|
|
| 2 |
const express = require('express');
|
| 3 |
const router = express.Router();
|
| 4 |
const OpenAI = require('openai');
|
| 5 |
+
const axios = require('axios'); // Imported Axios for Doubao
|
| 6 |
const { ConfigModel, User, AIUsageModel, ChatHistoryModel } = require('./models');
|
| 7 |
const { buildUserContext } = require('./ai-context');
|
| 8 |
|
|
|
|
| 72 |
return messages;
|
| 73 |
}
|
| 74 |
|
| 75 |
+
const PROVIDERS = { GEMINI: 'GEMINI', OPENROUTER: 'OPENROUTER', GEMMA: 'GEMMA', DOUBAO: 'DOUBAO' };
|
| 76 |
const DEFAULT_OPENROUTER_MODELS = ['qwen/qwen3-coder:free', 'openai/gpt-oss-120b:free', 'qwen/qwen3-235b-a22b:free', 'tngtech/deepseek-r1t-chimera:free'];
|
| 77 |
+
const DEFAULT_DOUBAO_MODEL = 'doubao-seed-1-6-251015';
|
| 78 |
|
| 79 |
// Runtime override logic
|
| 80 |
let runtimeProviderOrder = [];
|
|
|
|
| 88 |
|
| 89 |
function isQuotaError(e) {
|
| 90 |
const msg = (e.message || '').toLowerCase();
|
| 91 |
+
const responseData = e.response?.data ? JSON.stringify(e.response.data).toLowerCase() : '';
|
| 92 |
+
return e.status === 429 || e.status === 503 || msg.includes('quota') || msg.includes('overloaded') || msg.includes('resource_exhausted') || msg.includes('rate limit') || msg.includes('credits') || responseData.includes('insufficient_balance');
|
| 93 |
}
|
| 94 |
|
| 95 |
// Streaming Helpers
|
|
|
|
| 135 |
throw new Error("Gemini streaming failed (All keys/models exhausted)");
|
| 136 |
}
|
| 137 |
|
| 138 |
+
// --- DOUBAO CONTEXT & STREAMING ---
|
| 139 |
+
|
| 140 |
+
// Helper: Create/Reuse Context
|
| 141 |
+
async function getDoubaoContextId(apiKey, model, systemMessage, userId) {
|
| 142 |
+
// 1. Check User DB for existing ID
|
| 143 |
+
const user = await User.findById(userId);
|
| 144 |
+
if (user && user.doubaoContextId) {
|
| 145 |
+
console.log(`[AI] 🔄 Using existing Doubao Context ID: ${user.doubaoContextId}`);
|
| 146 |
+
return user.doubaoContextId;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
// 2. Create New Context
|
| 150 |
+
try {
|
| 151 |
+
console.log(`[AI] 🆕 Creating new Doubao Context for User: ${userId}`);
|
| 152 |
+
const response = await axios.post(
|
| 153 |
+
'https://ark.cn-beijing.volces.com/api/v3/context',
|
| 154 |
+
{
|
| 155 |
+
model: model,
|
| 156 |
+
messages: [systemMessage], // Initialize context with System Prompt
|
| 157 |
+
ttl: 3600 * 24 * 7 // Keep context for 7 days
|
| 158 |
+
},
|
| 159 |
+
{ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` } }
|
| 160 |
+
);
|
| 161 |
+
|
| 162 |
+
const newContextId = response.data.context_id || response.data.id;
|
| 163 |
+
|
| 164 |
+
if (newContextId) {
|
| 165 |
+
// Save to User
|
| 166 |
+
await User.findByIdAndUpdate(userId, { doubaoContextId: newContextId });
|
| 167 |
+
return newContextId;
|
| 168 |
+
}
|
| 169 |
+
} catch (e) {
|
| 170 |
+
console.error('Doubao Context Creation Failed:', e.response?.data || e.message);
|
| 171 |
+
// Fallback: Return null, chat will proceed without context_id (stateless)
|
| 172 |
+
}
|
| 173 |
+
return null;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
async function streamDoubao(baseParams, res, userId, mode = 'chat') {
|
| 177 |
+
const keys = await getKeyPool('doubao');
|
| 178 |
+
if (keys.length === 0) throw new Error("No Doubao API keys configured");
|
| 179 |
+
|
| 180 |
+
// Convert to OpenAI format
|
| 181 |
+
const messages = convertGeminiToOpenAI(baseParams);
|
| 182 |
+
if (messages.length === 0) throw new Error("Doubao: Empty messages");
|
| 183 |
+
|
| 184 |
+
const model = DEFAULT_DOUBAO_MODEL;
|
| 185 |
+
const apiUrl = 'https://ark.cn-beijing.volces.com/api/v3/chat/completions';
|
| 186 |
+
|
| 187 |
+
// Extract System Prompt for Context Creation if needed
|
| 188 |
+
const systemMsgObj = messages.find(m => m.role === 'system') || { role: 'system', content: 'You are a helpful assistant.' };
|
| 189 |
+
|
| 190 |
+
for (const apiKey of keys) {
|
| 191 |
+
try {
|
| 192 |
+
let requestBody = {
|
| 193 |
+
model: model,
|
| 194 |
+
stream: true,
|
| 195 |
+
messages: messages
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
// --- MODE SPECIFIC CONFIG ---
|
| 199 |
+
if (mode === 'evaluate') {
|
| 200 |
+
// Assessment Mode: Enable Thinking, Disable Context
|
| 201 |
+
console.log(`[AI] 🧠 Doubao Evaluate Mode: Thinking ON, Caching OFF`);
|
| 202 |
+
requestBody.thinking = { type: "enabled" }; // Enable reasoning
|
| 203 |
+
// Note: explicit caching params not sent implies no explicit context ID usage
|
| 204 |
+
} else {
|
| 205 |
+
// Chat Mode: Disable Thinking, Enable Context Caching
|
| 206 |
+
console.log(`[AI] 💬 Doubao Chat Mode: Thinking OFF, Context Caching ON`);
|
| 207 |
+
|
| 208 |
+
requestBody.thinking = { type: "disabled" };
|
| 209 |
+
|
| 210 |
+
// Try to get/create Context ID
|
| 211 |
+
if (userId) {
|
| 212 |
+
const contextId = await getDoubaoContextId(apiKey, model, systemMsgObj, userId);
|
| 213 |
+
if (contextId) {
|
| 214 |
+
requestBody.context_id = contextId;
|
| 215 |
+
// Important: When using context_id, Ark might expect only NEW messages?
|
| 216 |
+
// Ark docs vary, but usually standard is send full history or append.
|
| 217 |
+
// If strict context, we might strip system prompt from 'messages' as it's in context.
|
| 218 |
+
// For safety/compatibility with standard OpenAI adapters, we keep messages.
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
console.log(`[AI] 🚀 Requesting Doubao...`);
|
| 224 |
+
|
| 225 |
+
const response = await axios.post(apiUrl, requestBody, {
|
| 226 |
+
headers: {
|
| 227 |
+
'Content-Type': 'application/json',
|
| 228 |
+
'Authorization': `Bearer ${apiKey}`
|
| 229 |
+
},
|
| 230 |
+
responseType: 'stream'
|
| 231 |
+
});
|
| 232 |
+
|
| 233 |
+
console.log(`[AI] ✅ Connected to Doubao`);
|
| 234 |
+
recordUsage(model, PROVIDERS.DOUBAO);
|
| 235 |
+
|
| 236 |
+
let fullText = "";
|
| 237 |
+
let buffer = "";
|
| 238 |
+
|
| 239 |
+
return new Promise((resolve, reject) => {
|
| 240 |
+
response.data.on('data', chunk => {
|
| 241 |
+
buffer += chunk.toString();
|
| 242 |
+
const lines = buffer.split('\n');
|
| 243 |
+
buffer = lines.pop();
|
| 244 |
+
|
| 245 |
+
for (const line of lines) {
|
| 246 |
+
const trimmed = line.trim();
|
| 247 |
+
if (trimmed.startsWith('data: ')) {
|
| 248 |
+
const data = trimmed.slice(6);
|
| 249 |
+
if (data === '[DONE]') continue;
|
| 250 |
+
try {
|
| 251 |
+
const parsed = JSON.parse(data);
|
| 252 |
+
// Handle Thinking Content (Reasoning)
|
| 253 |
+
const reasoning = parsed.choices[0]?.delta?.reasoning_content;
|
| 254 |
+
const content = parsed.choices[0]?.delta?.content || '';
|
| 255 |
+
|
| 256 |
+
// We filter out reasoning content for the final user output in Chat,
|
| 257 |
+
// but for Evaluate we might want to keep it or just use the final content.
|
| 258 |
+
// Current frontend expects 'text'.
|
| 259 |
+
|
| 260 |
+
if (content) {
|
| 261 |
+
fullText += content;
|
| 262 |
+
res.write(`data: ${JSON.stringify({ text: content })}\n\n`);
|
| 263 |
+
if (res.flush) res.flush();
|
| 264 |
+
}
|
| 265 |
+
} catch (e) {}
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
response.data.on('end', () => {
|
| 271 |
+
resolve(fullText);
|
| 272 |
+
});
|
| 273 |
+
|
| 274 |
+
response.data.on('error', (err) => {
|
| 275 |
+
console.error('Doubao Stream Error', err);
|
| 276 |
+
reject(err);
|
| 277 |
+
});
|
| 278 |
+
});
|
| 279 |
+
|
| 280 |
+
} catch (e) {
|
| 281 |
+
console.warn(`[AI] ⚠️ Doubao Error: ${e.message}`, e.response?.data);
|
| 282 |
+
if (isQuotaError(e)) {
|
| 283 |
+
console.log(`[AI] 🔄 Quota exceeded, trying next key...`);
|
| 284 |
+
continue;
|
| 285 |
+
}
|
| 286 |
+
throw e;
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
throw new Error("Doubao streaming failed (All keys exhausted)");
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
async function streamOpenRouter(baseParams, res) {
|
| 293 |
const config = await ConfigModel.findOne({ key: 'main' });
|
| 294 |
const models = (config && config.openRouterModels?.length) ? config.openRouterModels.map(m => m.id) : DEFAULT_OPENROUTER_MODELS;
|
|
|
|
| 308 |
|
| 309 |
const client = new OpenAI({ baseURL, apiKey, defaultHeaders: { "HTTP-Referer": "https://smart.com", "X-Title": "Smart School" } });
|
| 310 |
|
|
|
|
| 311 |
const extraBody = {};
|
| 312 |
+
// If user uses Doubao via OpenRouter/Custom Proxy, also try to apply cache/thinking params
|
| 313 |
if (modelName.toLowerCase().includes('doubao')) {
|
|
|
|
|
|
|
| 314 |
extraBody.caching = { type: "enabled", prefix: true };
|
|
|
|
| 315 |
extraBody.thinking = { type: "disabled" };
|
| 316 |
}
|
|
|
|
| 317 |
|
| 318 |
try {
|
| 319 |
console.log(`[AI] 🚀 Attempting ${providerLabel} Model: ${modelName} (URL: ${baseURL})`);
|
|
|
|
| 387 |
throw new Error("Gemma stream failed");
|
| 388 |
}
|
| 389 |
|
| 390 |
+
async function streamContentWithSmartFallback(baseParams, res, userId, mode = 'chat') {
|
| 391 |
let hasAudio = false;
|
| 392 |
const contentsArray = Array.isArray(baseParams.contents) ? baseParams.contents : [baseParams.contents];
|
| 393 |
|
|
|
|
| 411 |
const config = await ConfigModel.findOne({ key: 'main' });
|
| 412 |
const configuredOrder = config?.aiProviderOrder && config.aiProviderOrder.length > 0
|
| 413 |
? config.aiProviderOrder
|
| 414 |
+
: [PROVIDERS.GEMINI, PROVIDERS.OPENROUTER, PROVIDERS.DOUBAO, PROVIDERS.GEMMA];
|
| 415 |
|
| 416 |
const runtimeSet = new Set(runtimeProviderOrder);
|
| 417 |
if (runtimeProviderOrder.length === 0 || runtimeProviderOrder.length !== configuredOrder.length || !configuredOrder.every(p => runtimeSet.has(p))) {
|
|
|
|
| 421 |
let finalError = null;
|
| 422 |
for (const provider of runtimeProviderOrder) {
|
| 423 |
try {
|
| 424 |
+
console.log(`[AI] 👉 Trying Provider: ${provider}... Mode: ${mode}`);
|
| 425 |
if (provider === PROVIDERS.GEMINI) return await streamGemini(baseParams, res);
|
| 426 |
else if (provider === PROVIDERS.OPENROUTER) return await streamOpenRouter(baseParams, res);
|
| 427 |
+
else if (provider === PROVIDERS.DOUBAO) return await streamDoubao(baseParams, res, userId, mode);
|
| 428 |
else if (provider === PROVIDERS.GEMMA) return await streamGemma(baseParams, res);
|
| 429 |
} catch (e) {
|
| 430 |
console.error(`[AI] ❌ Provider ${provider} Failed: ${e.message}`);
|
|
|
|
| 522 |
}));
|
| 523 |
|
| 524 |
// 3. PREPARE REQUEST
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
const fullContents = [...historyContext];
|
| 526 |
|
|
|
|
|
|
|
| 527 |
if (audio) {
|
|
|
|
| 528 |
if (fullContents.length > 0 && fullContents[fullContents.length - 1].role === 'user') {
|
| 529 |
fullContents.pop();
|
| 530 |
}
|
|
|
|
| 540 |
const combinedSystemInstruction = `${baseSystemInstruction}\n${contextPrompt}`;
|
| 541 |
// ---------------------------
|
| 542 |
|
| 543 |
+
// Pass userId for Doubao Context creation
|
| 544 |
const answerText = await streamContentWithSmartFallback({
|
| 545 |
contents: fullContents,
|
| 546 |
config: { systemInstruction: combinedSystemInstruction }
|
| 547 |
+
}, res, user._id, 'chat');
|
| 548 |
|
| 549 |
// 4. SAVE AI RESPONSE TO DB
|
| 550 |
if (answerText) {
|
|
|
|
| 582 |
// STREAMING ASSESSMENT ENDPOINT
|
| 583 |
router.post('/evaluate', checkAIAccess, async (req, res) => {
|
| 584 |
const { question, audio, image, images } = req.body;
|
| 585 |
+
|
| 586 |
+
// Extract User info for userId passing
|
| 587 |
+
const username = req.headers['x-user-username'];
|
| 588 |
+
const user = await User.findOne({ username });
|
| 589 |
+
|
| 590 |
res.setHeader('Content-Type', 'text/event-stream');
|
| 591 |
res.setHeader('Cache-Control', 'no-cache');
|
| 592 |
res.setHeader('Connection', 'keep-alive');
|
|
|
|
| 631 |
// CRITICAL FIX: Pass as array of objects for OpenRouter compatibility
|
| 632 |
contents: [{ role: 'user', parts: evalParts }],
|
| 633 |
// NO JSON MODE to allow progressive text streaming
|
| 634 |
+
}, res, user?._id, 'evaluate'); // Pass mode='evaluate'
|
| 635 |
|
| 636 |
// Extract Feedback for TTS
|
| 637 |
const feedbackMatch = fullText.match(/## Feedback\s+([\s\S]*?)(?=## Score|$)/i);
|
components/ai/AdminPanel.tsx
CHANGED
|
@@ -30,8 +30,11 @@ export const AdminPanel: React.FC = () => {
|
|
| 30 |
// Key Management
|
| 31 |
const [geminiKeys, setGeminiKeys] = useState<string[]>([]);
|
| 32 |
const [openRouterKeys, setOpenRouterKeys] = useState<string[]>([]);
|
|
|
|
|
|
|
| 33 |
const [newGeminiKey, setNewGeminiKey] = useState('');
|
| 34 |
const [newOpenRouterKey, setNewOpenRouterKey] = useState('');
|
|
|
|
| 35 |
|
| 36 |
// Model Management
|
| 37 |
const [orModels, setOrModels] = useState<OpenRouterModelConfig[]>([]);
|
|
@@ -40,7 +43,7 @@ export const AdminPanel: React.FC = () => {
|
|
| 40 |
const [newModelApiUrl, setNewModelApiUrl] = useState('');
|
| 41 |
|
| 42 |
// Provider Priority
|
| 43 |
-
const [providerOrder, setProviderOrder] = useState<string[]>(['GEMINI', 'OPENROUTER', 'GEMMA']);
|
| 44 |
|
| 45 |
useEffect(() => {
|
| 46 |
loadData();
|
|
@@ -53,6 +56,7 @@ export const AdminPanel: React.FC = () => {
|
|
| 53 |
if (cfg.apiKeys) {
|
| 54 |
setGeminiKeys(cfg.apiKeys.gemini || []);
|
| 55 |
setOpenRouterKeys(cfg.apiKeys.openrouter || []);
|
|
|
|
| 56 |
}
|
| 57 |
setOrModels(cfg.openRouterModels && cfg.openRouterModels.length > 0 ? cfg.openRouterModels : DEFAULT_OR_MODELS);
|
| 58 |
|
|
@@ -79,16 +83,20 @@ export const AdminPanel: React.FC = () => {
|
|
| 79 |
}
|
| 80 |
};
|
| 81 |
|
| 82 |
-
const handleAddKey = (type: 'gemini' | 'openrouter') => {
|
| 83 |
-
const
|
|
|
|
| 84 |
if (!key) return;
|
|
|
|
| 85 |
if (type === 'gemini') { setGeminiKeys([...geminiKeys, key]); setNewGeminiKey(''); }
|
| 86 |
-
else { setOpenRouterKeys([...openRouterKeys, key]); setNewOpenRouterKey(''); }
|
|
|
|
| 87 |
};
|
| 88 |
|
| 89 |
-
const removeKey = (type: 'gemini' | 'openrouter', index: number) => {
|
| 90 |
if (type === 'gemini') setGeminiKeys(geminiKeys.filter((_, i) => i !== index));
|
| 91 |
-
else setOpenRouterKeys(openRouterKeys.filter((_, i) => i !== index));
|
|
|
|
| 92 |
};
|
| 93 |
|
| 94 |
const handleAddModel = () => {
|
|
@@ -122,7 +130,7 @@ export const AdminPanel: React.FC = () => {
|
|
| 122 |
try {
|
| 123 |
await api.config.save({
|
| 124 |
...systemConfig,
|
| 125 |
-
apiKeys: { gemini: geminiKeys, openrouter: openRouterKeys },
|
| 126 |
openRouterModels: orModels,
|
| 127 |
aiProviderOrder: providerOrder
|
| 128 |
});
|
|
@@ -187,18 +195,27 @@ export const AdminPanel: React.FC = () => {
|
|
| 187 |
</div>
|
| 188 |
<div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm">
|
| 189 |
<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>
|
| 190 |
-
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
<
|
| 194 |
-
<
|
| 195 |
-
<div className="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
</div>
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
<
|
| 200 |
-
<
|
| 201 |
-
<div className="
|
|
|
|
| 202 |
</div>
|
| 203 |
</div>
|
| 204 |
|
|
@@ -235,4 +252,4 @@ export const AdminPanel: React.FC = () => {
|
|
| 235 |
</div>
|
| 236 |
</div>
|
| 237 |
);
|
| 238 |
-
};
|
|
|
|
| 30 |
// Key Management
|
| 31 |
const [geminiKeys, setGeminiKeys] = useState<string[]>([]);
|
| 32 |
const [openRouterKeys, setOpenRouterKeys] = useState<string[]>([]);
|
| 33 |
+
const [doubaoKeys, setDoubaoKeys] = useState<string[]>([]); // New Doubao State
|
| 34 |
+
|
| 35 |
const [newGeminiKey, setNewGeminiKey] = useState('');
|
| 36 |
const [newOpenRouterKey, setNewOpenRouterKey] = useState('');
|
| 37 |
+
const [newDoubaoKey, setNewDoubaoKey] = useState(''); // New Doubao Input
|
| 38 |
|
| 39 |
// Model Management
|
| 40 |
const [orModels, setOrModels] = useState<OpenRouterModelConfig[]>([]);
|
|
|
|
| 43 |
const [newModelApiUrl, setNewModelApiUrl] = useState('');
|
| 44 |
|
| 45 |
// Provider Priority
|
| 46 |
+
const [providerOrder, setProviderOrder] = useState<string[]>(['GEMINI', 'OPENROUTER', 'DOUBAO', 'GEMMA']);
|
| 47 |
|
| 48 |
useEffect(() => {
|
| 49 |
loadData();
|
|
|
|
| 56 |
if (cfg.apiKeys) {
|
| 57 |
setGeminiKeys(cfg.apiKeys.gemini || []);
|
| 58 |
setOpenRouterKeys(cfg.apiKeys.openrouter || []);
|
| 59 |
+
setDoubaoKeys(cfg.apiKeys.doubao || []); // Load Doubao keys
|
| 60 |
}
|
| 61 |
setOrModels(cfg.openRouterModels && cfg.openRouterModels.length > 0 ? cfg.openRouterModels : DEFAULT_OR_MODELS);
|
| 62 |
|
|
|
|
| 83 |
}
|
| 84 |
};
|
| 85 |
|
| 86 |
+
const handleAddKey = (type: 'gemini' | 'openrouter' | 'doubao') => {
|
| 87 |
+
const keyMap = { gemini: newGeminiKey, openrouter: newOpenRouterKey, doubao: newDoubaoKey };
|
| 88 |
+
const key = keyMap[type].trim();
|
| 89 |
if (!key) return;
|
| 90 |
+
|
| 91 |
if (type === 'gemini') { setGeminiKeys([...geminiKeys, key]); setNewGeminiKey(''); }
|
| 92 |
+
else if (type === 'openrouter') { setOpenRouterKeys([...openRouterKeys, key]); setNewOpenRouterKey(''); }
|
| 93 |
+
else if (type === 'doubao') { setDoubaoKeys([...doubaoKeys, key]); setNewDoubaoKey(''); }
|
| 94 |
};
|
| 95 |
|
| 96 |
+
const removeKey = (type: 'gemini' | 'openrouter' | 'doubao', index: number) => {
|
| 97 |
if (type === 'gemini') setGeminiKeys(geminiKeys.filter((_, i) => i !== index));
|
| 98 |
+
else if (type === 'openrouter') setOpenRouterKeys(openRouterKeys.filter((_, i) => i !== index));
|
| 99 |
+
else if (type === 'doubao') setDoubaoKeys(doubaoKeys.filter((_, i) => i !== index));
|
| 100 |
};
|
| 101 |
|
| 102 |
const handleAddModel = () => {
|
|
|
|
| 130 |
try {
|
| 131 |
await api.config.save({
|
| 132 |
...systemConfig,
|
| 133 |
+
apiKeys: { gemini: geminiKeys, openrouter: openRouterKeys, doubao: doubaoKeys },
|
| 134 |
openRouterModels: orModels,
|
| 135 |
aiProviderOrder: providerOrder
|
| 136 |
});
|
|
|
|
| 195 |
</div>
|
| 196 |
<div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm">
|
| 197 |
<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>
|
| 198 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
| 199 |
+
{/* Gemini Keys */}
|
| 200 |
+
<div className="bg-gray-50/50 p-3 rounded-xl border border-gray-100">
|
| 201 |
+
<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>
|
| 202 |
+
<p className="text-[10px] text-gray-400 mb-3">当额度耗尽时自动切换。</p>
|
| 203 |
+
<div className="space-y-2 mb-3 max-h-32 overflow-y-auto custom-scrollbar">{geminiKeys.map((k, idx) => (<div key={idx} className="flex gap-2 items-center bg-white 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>
|
| 204 |
+
<div className="flex gap-2"><input className="flex-1 border border-gray-300 rounded px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-blue-500" placeholder="输入 Gemini API Key" value={newGeminiKey} onChange={e => setNewGeminiKey(e.target.value)}/><button onClick={() => handleAddKey('gemini')} className="bg-white hover:bg-gray-100 text-gray-600 px-2 py-1 rounded border border-gray-300"><Plus size={14}/></button></div>
|
| 205 |
+
</div>
|
| 206 |
+
{/* Doubao Keys */}
|
| 207 |
+
<div className="bg-gray-50/50 p-3 rounded-xl border border-gray-100">
|
| 208 |
+
<div className="flex items-center justify-between mb-2"><label className="text-sm font-bold text-gray-700">Doubao (豆包/火山引擎)</label><span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{doubaoKeys.length} 个</span></div>
|
| 209 |
+
<p className="text-[10px] text-gray-400 mb-3">使用原生 Axios 调用。</p>
|
| 210 |
+
<div className="space-y-2 mb-3 max-h-32 overflow-y-auto custom-scrollbar">{doubaoKeys.map((k, idx) => (<div key={idx} className="flex gap-2 items-center bg-white 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('doubao', idx)} className="text-gray-400 hover:text-red-500"><Trash2 size={14}/></button></div>))}</div>
|
| 211 |
+
<div className="flex gap-2"><input className="flex-1 border border-gray-300 rounded px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-green-500" placeholder="输入 Doubao API Key" value={newDoubaoKey} onChange={e => setNewDoubaoKey(e.target.value)}/><button onClick={() => handleAddKey('doubao')} className="bg-white hover:bg-gray-100 text-gray-600 px-2 py-1 rounded border border-gray-300"><Plus size={14}/></button></div>
|
| 212 |
</div>
|
| 213 |
+
{/* OpenRouter Keys */}
|
| 214 |
+
<div className="bg-gray-50/50 p-3 rounded-xl border border-gray-100">
|
| 215 |
+
<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>
|
| 216 |
+
<p className="text-[10px] text-gray-400 mb-3">备用线路,支持自定义模型。</p>
|
| 217 |
+
<div className="space-y-2 mb-3 max-h-32 overflow-y-auto custom-scrollbar">{openRouterKeys.map((k, idx) => (<div key={idx} className="flex gap-2 items-center bg-white 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>
|
| 218 |
+
<div className="flex gap-2"><input className="flex-1 border border-gray-300 rounded px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-purple-500" placeholder="输入 OpenRouter Key" value={newOpenRouterKey} onChange={e => setNewOpenRouterKey(e.target.value)}/><button onClick={() => handleAddKey('openrouter')} className="bg-white hover:bg-gray-100 text-gray-600 px-2 py-1 rounded border border-gray-300"><Plus size={14}/></button></div>
|
| 219 |
</div>
|
| 220 |
</div>
|
| 221 |
|
|
|
|
| 252 |
</div>
|
| 253 |
</div>
|
| 254 |
);
|
| 255 |
+
};
|
models.js
CHANGED
|
@@ -26,6 +26,7 @@ const UserSchema = new mongoose.Schema({
|
|
| 26 |
seatNo: String,
|
| 27 |
idCard: String,
|
| 28 |
aiAccess: { type: Boolean, default: false },
|
|
|
|
| 29 |
menuOrder: [String], // NEW
|
| 30 |
classApplication: {
|
| 31 |
type: { type: String },
|
|
@@ -125,7 +126,8 @@ const ConfigSchema = new mongoose.Schema({
|
|
| 125 |
periodConfig: [{ period: Number, name: String, startTime: String, endTime: String }],
|
| 126 |
apiKeys: {
|
| 127 |
gemini: [String],
|
| 128 |
-
openrouter: [String]
|
|
|
|
| 129 |
},
|
| 130 |
openRouterModels: [{
|
| 131 |
id: String,
|
|
@@ -294,4 +296,4 @@ module.exports = {
|
|
| 294 |
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 295 |
AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
|
| 296 |
WishModel, FeedbackModel, TodoModel, AIUsageModel, ChatHistoryModel
|
| 297 |
-
};
|
|
|
|
| 26 |
seatNo: String,
|
| 27 |
idCard: String,
|
| 28 |
aiAccess: { type: Boolean, default: false },
|
| 29 |
+
doubaoContextId: { type: String }, // NEW: Stores the Doubao Context ID for this user
|
| 30 |
menuOrder: [String], // NEW
|
| 31 |
classApplication: {
|
| 32 |
type: { type: String },
|
|
|
|
| 126 |
periodConfig: [{ period: Number, name: String, startTime: String, endTime: String }],
|
| 127 |
apiKeys: {
|
| 128 |
gemini: [String],
|
| 129 |
+
openrouter: [String],
|
| 130 |
+
doubao: [String] // NEW: Doubao Key Pool
|
| 131 |
},
|
| 132 |
openRouterModels: [{
|
| 133 |
id: String,
|
|
|
|
| 296 |
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 297 |
AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
|
| 298 |
WishModel, FeedbackModel, TodoModel, AIUsageModel, ChatHistoryModel
|
| 299 |
+
};
|
types.ts
CHANGED
|
@@ -189,11 +189,12 @@ export interface SystemConfig {
|
|
| 189 |
emailNotify: boolean;
|
| 190 |
enableAI?: boolean;
|
| 191 |
aiTotalCalls?: number;
|
| 192 |
-
aiProviderOrder?: string[]; // 'GEMINI', 'OPENROUTER', 'GEMMA'
|
| 193 |
periodConfig?: PeriodConfig[];
|
| 194 |
apiKeys?: {
|
| 195 |
gemini?: string[];
|
| 196 |
openrouter?: string[];
|
|
|
|
| 197 |
};
|
| 198 |
openRouterModels?: OpenRouterModelConfig[];
|
| 199 |
}
|
|
@@ -388,4 +389,4 @@ export interface AIChatMessage {
|
|
| 388 |
audio?: string;
|
| 389 |
isAudioMessage?: boolean;
|
| 390 |
timestamp: number;
|
| 391 |
-
}
|
|
|
|
| 189 |
emailNotify: boolean;
|
| 190 |
enableAI?: boolean;
|
| 191 |
aiTotalCalls?: number;
|
| 192 |
+
aiProviderOrder?: string[]; // 'GEMINI', 'OPENROUTER', 'GEMMA', 'DOUBAO'
|
| 193 |
periodConfig?: PeriodConfig[];
|
| 194 |
apiKeys?: {
|
| 195 |
gemini?: string[];
|
| 196 |
openrouter?: string[];
|
| 197 |
+
doubao?: string[]; // NEW: Doubao keys
|
| 198 |
};
|
| 199 |
openRouterModels?: OpenRouterModelConfig[];
|
| 200 |
}
|
|
|
|
| 389 |
audio?: string;
|
| 390 |
isAudioMessage?: boolean;
|
| 391 |
timestamp: number;
|
| 392 |
+
}
|