Spaces:
Running
Running
Upload 53 files
Browse files- ai-routes.js +102 -8
- models.js +14 -5
- pages/AIAssistant.tsx +79 -16
- services/api.ts +2 -1
ai-routes.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
const express = require('express');
|
| 3 |
const router = express.Router();
|
| 4 |
const OpenAI = require('openai');
|
| 5 |
-
const { ConfigModel, User } = require('./models');
|
| 6 |
|
| 7 |
// --- Key Management & Rotation ---
|
| 8 |
|
|
@@ -29,6 +29,23 @@ async function getKeyPool(type) {
|
|
| 29 |
return pool;
|
| 30 |
}
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
// --- Helpers ---
|
| 33 |
|
| 34 |
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -147,7 +164,12 @@ async function callGeminiProvider(baseParams) {
|
|
| 147 |
try {
|
| 148 |
console.log(`🚀 [AI Debug] Calling Gemini non-stream: ${modelName} (Key ends: ${apiKey.slice(-4)})`);
|
| 149 |
const currentParams = { ...baseParams, model: modelName };
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
} catch (e) {
|
| 152 |
lastError = e;
|
| 153 |
console.error(`⚠️ [AI Debug] Gemini ${modelName} Error with key ${apiKey.slice(-4)}:`, e.message);
|
|
@@ -201,14 +223,16 @@ async function callOpenRouterProvider(baseParams) {
|
|
| 201 |
if (!completion || !completion.choices || !completion.choices[0]) {
|
| 202 |
throw new Error(`Invalid response structure from ${modelName}`);
|
| 203 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
const content = completion.choices[0].message.content || "";
|
| 205 |
return { text: content };
|
| 206 |
} catch (e) {
|
| 207 |
lastError = e;
|
| 208 |
console.warn(`⚠️ [AI Debug] OpenRouter Model ${modelName} failed with key ${apiKey.slice(-4)}.`, e.message);
|
| 209 |
if (isQuotaError(e)) break; // Try next key
|
| 210 |
-
// Keep trying models if it's not a quota error? OpenRouter often has specific model downtimes.
|
| 211 |
-
// But for key rotation logic, usually 429 applies to account.
|
| 212 |
}
|
| 213 |
}
|
| 214 |
}
|
|
@@ -236,7 +260,12 @@ async function callGemmaProvider(baseParams) {
|
|
| 236 |
model: modelName,
|
| 237 |
config: gemmaConfig
|
| 238 |
};
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
} catch (e) {
|
| 241 |
lastError = e;
|
| 242 |
console.warn(`⚠️ [AI Debug] Backup Model ${modelName} failed with key ${apiKey.slice(-4)}.`, e.message);
|
|
@@ -297,6 +326,9 @@ async function streamGemini(baseParams, res) {
|
|
| 297 |
const currentParams = { ...baseParams, model: modelName };
|
| 298 |
const result = await client.models.generateContentStream(currentParams);
|
| 299 |
|
|
|
|
|
|
|
|
|
|
| 300 |
let fullText = "";
|
| 301 |
for await (const chunk of result) {
|
| 302 |
const chunkText = chunk.text;
|
|
@@ -352,6 +384,10 @@ async function streamOpenRouter(baseParams, res) {
|
|
| 352 |
messages: messages,
|
| 353 |
stream: true
|
| 354 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
let fullText = '';
|
| 356 |
for await (const chunk of stream) {
|
| 357 |
const text = chunk.choices[0]?.delta?.content || '';
|
|
@@ -393,6 +429,10 @@ async function streamGemma(baseParams, res) {
|
|
| 393 |
config: gemmaConfig
|
| 394 |
};
|
| 395 |
const result = await client.models.generateContentStream(currentParams);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
let fullText = "";
|
| 397 |
for await (const chunk of result) {
|
| 398 |
const chunkText = chunk.text;
|
|
@@ -476,6 +516,63 @@ const checkAIAccess = async (req, res, next) => {
|
|
| 476 |
|
| 477 |
// --- Routes ---
|
| 478 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
// POST /api/ai/reset-pool (New Endpoint)
|
| 480 |
router.post('/reset-pool', checkAIAccess, (req, res) => {
|
| 481 |
resetProviderOrder();
|
|
@@ -557,7 +654,6 @@ router.post('/chat', checkAIAccess, async (req, res) => {
|
|
| 557 |
}
|
| 558 |
}
|
| 559 |
|
| 560 |
-
await ConfigModel.findOneAndUpdate({ key: 'main' }, { $inc: { aiTotalCalls: 1 } }, { upsert: true });
|
| 561 |
res.write('data: [DONE]\n\n');
|
| 562 |
res.end();
|
| 563 |
|
|
@@ -643,8 +739,6 @@ router.post('/evaluate', checkAIAccess, async (req, res) => {
|
|
| 643 |
}
|
| 644 |
}
|
| 645 |
|
| 646 |
-
await ConfigModel.findOneAndUpdate({ key: 'main' }, { $inc: { aiTotalCalls: 1 } }, { upsert: true });
|
| 647 |
-
|
| 648 |
res.json({
|
| 649 |
...resultJson,
|
| 650 |
audio: feedbackAudio,
|
|
|
|
| 2 |
const express = require('express');
|
| 3 |
const router = express.Router();
|
| 4 |
const OpenAI = require('openai');
|
| 5 |
+
const { ConfigModel, User, AIUsageModel } = require('./models');
|
| 6 |
|
| 7 |
// --- Key Management & Rotation ---
|
| 8 |
|
|
|
|
| 29 |
return pool;
|
| 30 |
}
|
| 31 |
|
| 32 |
+
// --- Usage Tracking Helper ---
|
| 33 |
+
async function recordUsage(model, provider) {
|
| 34 |
+
try {
|
| 35 |
+
const today = new Date().toISOString().split('T')[0];
|
| 36 |
+
// Upsert the usage record for this specific day/model/provider combination
|
| 37 |
+
await AIUsageModel.findOneAndUpdate(
|
| 38 |
+
{ date: today, model: model, provider: provider },
|
| 39 |
+
{ $inc: { count: 1 } },
|
| 40 |
+
{ upsert: true, new: true }
|
| 41 |
+
);
|
| 42 |
+
// Also increment total stats for backward compatibility
|
| 43 |
+
await ConfigModel.findOneAndUpdate({ key: 'main' }, { $inc: { aiTotalCalls: 1 } }, { upsert: true });
|
| 44 |
+
} catch (e) {
|
| 45 |
+
console.error("Failed to record AI usage stats:", e);
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
// --- Helpers ---
|
| 50 |
|
| 51 |
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
| 164 |
try {
|
| 165 |
console.log(`🚀 [AI Debug] Calling Gemini non-stream: ${modelName} (Key ends: ${apiKey.slice(-4)})`);
|
| 166 |
const currentParams = { ...baseParams, model: modelName };
|
| 167 |
+
const result = await callAIWithRetry(() => client.models.generateContent(currentParams), 1);
|
| 168 |
+
|
| 169 |
+
// Track Usage on Success
|
| 170 |
+
recordUsage(modelName, PROVIDERS.GEMINI);
|
| 171 |
+
|
| 172 |
+
return result;
|
| 173 |
} catch (e) {
|
| 174 |
lastError = e;
|
| 175 |
console.error(`⚠️ [AI Debug] Gemini ${modelName} Error with key ${apiKey.slice(-4)}:`, e.message);
|
|
|
|
| 223 |
if (!completion || !completion.choices || !completion.choices[0]) {
|
| 224 |
throw new Error(`Invalid response structure from ${modelName}`);
|
| 225 |
}
|
| 226 |
+
|
| 227 |
+
// Track Usage on Success
|
| 228 |
+
recordUsage(modelName, PROVIDERS.OPENROUTER);
|
| 229 |
+
|
| 230 |
const content = completion.choices[0].message.content || "";
|
| 231 |
return { text: content };
|
| 232 |
} catch (e) {
|
| 233 |
lastError = e;
|
| 234 |
console.warn(`⚠️ [AI Debug] OpenRouter Model ${modelName} failed with key ${apiKey.slice(-4)}.`, e.message);
|
| 235 |
if (isQuotaError(e)) break; // Try next key
|
|
|
|
|
|
|
| 236 |
}
|
| 237 |
}
|
| 238 |
}
|
|
|
|
| 260 |
model: modelName,
|
| 261 |
config: gemmaConfig
|
| 262 |
};
|
| 263 |
+
const result = await callAIWithRetry(() => client.models.generateContent(currentParams), 1);
|
| 264 |
+
|
| 265 |
+
// Track Usage on Success
|
| 266 |
+
recordUsage(modelName, PROVIDERS.GEMMA);
|
| 267 |
+
|
| 268 |
+
return result;
|
| 269 |
} catch (e) {
|
| 270 |
lastError = e;
|
| 271 |
console.warn(`⚠️ [AI Debug] Backup Model ${modelName} failed with key ${apiKey.slice(-4)}.`, e.message);
|
|
|
|
| 326 |
const currentParams = { ...baseParams, model: modelName };
|
| 327 |
const result = await client.models.generateContentStream(currentParams);
|
| 328 |
|
| 329 |
+
// Track Usage on Success (Stream Start)
|
| 330 |
+
recordUsage(modelName, PROVIDERS.GEMINI);
|
| 331 |
+
|
| 332 |
let fullText = "";
|
| 333 |
for await (const chunk of result) {
|
| 334 |
const chunkText = chunk.text;
|
|
|
|
| 384 |
messages: messages,
|
| 385 |
stream: true
|
| 386 |
});
|
| 387 |
+
|
| 388 |
+
// Track Usage on Success (Stream Start)
|
| 389 |
+
recordUsage(modelName, PROVIDERS.OPENROUTER);
|
| 390 |
+
|
| 391 |
let fullText = '';
|
| 392 |
for await (const chunk of stream) {
|
| 393 |
const text = chunk.choices[0]?.delta?.content || '';
|
|
|
|
| 429 |
config: gemmaConfig
|
| 430 |
};
|
| 431 |
const result = await client.models.generateContentStream(currentParams);
|
| 432 |
+
|
| 433 |
+
// Track Usage on Success (Stream Start)
|
| 434 |
+
recordUsage(modelName, PROVIDERS.GEMMA);
|
| 435 |
+
|
| 436 |
let fullText = "";
|
| 437 |
for await (const chunk of result) {
|
| 438 |
const chunkText = chunk.text;
|
|
|
|
| 516 |
|
| 517 |
// --- Routes ---
|
| 518 |
|
| 519 |
+
// GET /api/ai/stats (New Detailed Stats Endpoint)
|
| 520 |
+
router.get('/stats', checkAIAccess, async (req, res) => {
|
| 521 |
+
try {
|
| 522 |
+
const config = await ConfigModel.findOne({ key: 'main' });
|
| 523 |
+
const totalCalls = config ? (config.aiTotalCalls || 0) : 0;
|
| 524 |
+
|
| 525 |
+
// Get Today's Date
|
| 526 |
+
const todayStr = new Date().toISOString().split('T')[0];
|
| 527 |
+
|
| 528 |
+
// Get dates for the last 7 days
|
| 529 |
+
const last7Days = [];
|
| 530 |
+
for (let i = 6; i >= 0; i--) {
|
| 531 |
+
const d = new Date();
|
| 532 |
+
d.setDate(d.getDate() - i);
|
| 533 |
+
last7Days.push(d.toISOString().split('T')[0]);
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
// Aggregate usage data from AIUsageModel
|
| 537 |
+
const allUsage = await AIUsageModel.find({});
|
| 538 |
+
|
| 539 |
+
// 1. Calculate Today's Calls
|
| 540 |
+
let todayCount = 0;
|
| 541 |
+
allUsage.forEach(u => {
|
| 542 |
+
if (u.date === todayStr) todayCount += u.count;
|
| 543 |
+
});
|
| 544 |
+
|
| 545 |
+
// 2. Calculate Weekly Trend (Date -> Count)
|
| 546 |
+
const dailyTrend = last7Days.map(date => {
|
| 547 |
+
const count = allUsage.filter(u => u.date === date).reduce((sum, u) => sum + u.count, 0);
|
| 548 |
+
return { date, count };
|
| 549 |
+
});
|
| 550 |
+
|
| 551 |
+
// 3. Calculate Model Distribution (Model Name -> Total Count)
|
| 552 |
+
const modelStats = {};
|
| 553 |
+
allUsage.forEach(u => {
|
| 554 |
+
if (!modelStats[u.model]) modelStats[u.model] = 0;
|
| 555 |
+
modelStats[u.model] += u.count;
|
| 556 |
+
});
|
| 557 |
+
|
| 558 |
+
const modelDistribution = Object.keys(modelStats).map(key => ({
|
| 559 |
+
name: key,
|
| 560 |
+
value: modelStats[key]
|
| 561 |
+
})).sort((a,b) => b.value - a.value);
|
| 562 |
+
|
| 563 |
+
res.json({
|
| 564 |
+
totalCalls,
|
| 565 |
+
todayCount,
|
| 566 |
+
dailyTrend,
|
| 567 |
+
modelDistribution
|
| 568 |
+
});
|
| 569 |
+
|
| 570 |
+
} catch (e) {
|
| 571 |
+
console.error("Stats Error:", e);
|
| 572 |
+
res.status(500).json({ error: e.message });
|
| 573 |
+
}
|
| 574 |
+
});
|
| 575 |
+
|
| 576 |
// POST /api/ai/reset-pool (New Endpoint)
|
| 577 |
router.post('/reset-pool', checkAIAccess, (req, res) => {
|
| 578 |
resetProviderOrder();
|
|
|
|
| 654 |
}
|
| 655 |
}
|
| 656 |
|
|
|
|
| 657 |
res.write('data: [DONE]\n\n');
|
| 658 |
res.end();
|
| 659 |
|
|
|
|
| 739 |
}
|
| 740 |
}
|
| 741 |
|
|
|
|
|
|
|
| 742 |
res.json({
|
| 743 |
...resultJson,
|
| 744 |
audio: feedbackAudio,
|
models.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
|
| 2 |
const mongoose = require('mongoose');
|
| 3 |
|
| 4 |
-
// ... (Previous Models)
|
| 5 |
-
|
| 6 |
const SchoolSchema = new mongoose.Schema({ name: String, code: String });
|
| 7 |
const School = mongoose.model('School', SchoolSchema);
|
| 8 |
|
|
@@ -110,7 +109,7 @@ const ConfigSchema = new mongoose.Schema({
|
|
| 110 |
semesters: [String],
|
| 111 |
allowRegister: Boolean,
|
| 112 |
allowAdminRegister: Boolean,
|
| 113 |
-
allowPrincipalRegister: Boolean,
|
| 114 |
allowStudentRegister: Boolean,
|
| 115 |
maintenanceMode: Boolean,
|
| 116 |
emailNotify: Boolean,
|
|
@@ -241,7 +240,6 @@ const FeedbackSchema = new mongoose.Schema({
|
|
| 241 |
});
|
| 242 |
const FeedbackModel = mongoose.model('Feedback', FeedbackSchema);
|
| 243 |
|
| 244 |
-
// NEW: Todo Model
|
| 245 |
const TodoSchema = new mongoose.Schema({
|
| 246 |
userId: String,
|
| 247 |
content: String,
|
|
@@ -250,9 +248,20 @@ const TodoSchema = new mongoose.Schema({
|
|
| 250 |
});
|
| 251 |
const TodoModel = mongoose.model('Todo', TodoSchema);
|
| 252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
module.exports = {
|
| 254 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 255 |
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 256 |
AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
|
| 257 |
-
WishModel, FeedbackModel, TodoModel
|
| 258 |
};
|
|
|
|
| 1 |
|
| 2 |
const mongoose = require('mongoose');
|
| 3 |
|
| 4 |
+
// ... (Previous Models: School, User, Student, Course, Score, Class, Subject, Exam Models - No Change)
|
|
|
|
| 5 |
const SchoolSchema = new mongoose.Schema({ name: String, code: String });
|
| 6 |
const School = mongoose.model('School', SchoolSchema);
|
| 7 |
|
|
|
|
| 109 |
semesters: [String],
|
| 110 |
allowRegister: Boolean,
|
| 111 |
allowAdminRegister: Boolean,
|
| 112 |
+
allowPrincipalRegister: Boolean,
|
| 113 |
allowStudentRegister: Boolean,
|
| 114 |
maintenanceMode: Boolean,
|
| 115 |
emailNotify: Boolean,
|
|
|
|
| 240 |
});
|
| 241 |
const FeedbackModel = mongoose.model('Feedback', FeedbackSchema);
|
| 242 |
|
|
|
|
| 243 |
const TodoSchema = new mongoose.Schema({
|
| 244 |
userId: String,
|
| 245 |
content: String,
|
|
|
|
| 248 |
});
|
| 249 |
const TodoModel = mongoose.model('Todo', TodoSchema);
|
| 250 |
|
| 251 |
+
// NEW: Detailed AI Usage Stats
|
| 252 |
+
const AIUsageSchema = new mongoose.Schema({
|
| 253 |
+
date: String, // Format: YYYY-MM-DD
|
| 254 |
+
model: String,
|
| 255 |
+
provider: String,
|
| 256 |
+
count: { type: Number, default: 0 }
|
| 257 |
+
});
|
| 258 |
+
// Optimize lookups by date and model
|
| 259 |
+
AIUsageSchema.index({ date: 1, model: 1, provider: 1 }, { unique: true });
|
| 260 |
+
const AIUsageModel = mongoose.model('AIUsage', AIUsageSchema);
|
| 261 |
+
|
| 262 |
module.exports = {
|
| 263 |
School, User, Student, Course, Score, ClassModel, SubjectModel, ExamModel, ScheduleModel,
|
| 264 |
ConfigModel, NotificationModel, GameSessionModel, StudentRewardModel, LuckyDrawConfigModel, GameMonsterConfigModel, GameZenConfigModel,
|
| 265 |
AchievementConfigModel, TeacherExchangeConfigModel, StudentAchievementModel, AttendanceModel, LeaveRequestModel, SchoolCalendarModel,
|
| 266 |
+
WishModel, FeedbackModel, TodoModel, AIUsageModel
|
| 267 |
};
|
pages/AIAssistant.tsx
CHANGED
|
@@ -2,10 +2,11 @@
|
|
| 2 |
import React, { useState, useRef, useEffect } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { AIChatMessage, SystemConfig, UserRole, OpenRouterModelConfig } from '../types';
|
| 5 |
-
import { Bot, Mic, Square, Play, Volume2, Send, CheckCircle, Brain, Sparkles, Loader2, StopCircle, Trash2, Image as ImageIcon, X, AlertTriangle, ShieldCheck, Activity, Power, ExternalLink, Key, Plus, Save, ArrowUp, ArrowDown } from 'lucide-react';
|
| 6 |
import ReactMarkdown from 'react-markdown';
|
| 7 |
import remarkGfm from 'remark-gfm';
|
| 8 |
import { Toast, ToastState } from '../components/Toast';
|
|
|
|
| 9 |
|
| 10 |
// Utility to handle Base64 conversion
|
| 11 |
const blobToBase64 = (blob: Blob): Promise<string> => {
|
|
@@ -92,6 +93,9 @@ const DEFAULT_OR_MODELS = [
|
|
| 92 |
{ id: 'tngtech/deepseek-r1t-chimera:free', name: 'DeepSeek R1T', isCustom: false }
|
| 93 |
];
|
| 94 |
|
|
|
|
|
|
|
|
|
|
| 95 |
export const AIAssistant: React.FC = () => {
|
| 96 |
const currentUser = api.auth.getCurrentUser();
|
| 97 |
const isAdmin = currentUser?.role === UserRole.ADMIN;
|
|
@@ -100,6 +104,14 @@ export const AIAssistant: React.FC = () => {
|
|
| 100 |
const [systemConfig, setSystemConfig] = useState<SystemConfig | null>(null);
|
| 101 |
const [configLoading, setConfigLoading] = useState(true);
|
| 102 |
const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
// Chat State with Persistence
|
| 105 |
const [activeTab, setActiveTab] = useState<'chat' | 'assessment'>('chat');
|
|
@@ -160,12 +172,16 @@ export const AIAssistant: React.FC = () => {
|
|
| 160 |
|
| 161 |
const cfg = await api.config.get();
|
| 162 |
setSystemConfig(cfg);
|
| 163 |
-
if (isAdmin && cfg.apiKeys) {
|
| 164 |
-
setGeminiKeys(cfg.apiKeys.gemini || []);
|
| 165 |
-
setOpenRouterKeys(cfg.apiKeys.openrouter || []);
|
| 166 |
-
}
|
| 167 |
if (isAdmin) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
setOrModels(cfg.openRouterModels && cfg.openRouterModels.length > 0 ? cfg.openRouterModels : DEFAULT_OR_MODELS);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
}
|
| 170 |
} catch (e) {
|
| 171 |
console.error("Init failed", e);
|
|
@@ -557,16 +573,62 @@ export const AIAssistant: React.FC = () => {
|
|
| 557 |
</div>
|
| 558 |
|
| 559 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
<
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
|
|
|
|
|
|
| 566 |
</div>
|
| 567 |
-
<div className="
|
| 568 |
-
<
|
| 569 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
</div>
|
| 571 |
</div>
|
| 572 |
</div>
|
|
@@ -707,7 +769,8 @@ export const AIAssistant: React.FC = () => {
|
|
| 707 |
);
|
| 708 |
}
|
| 709 |
|
| 710 |
-
//
|
|
|
|
| 711 |
if (!systemConfig?.enableAI) {
|
| 712 |
return (
|
| 713 |
<div className="flex flex-col items-center justify-center h-full text-center p-6 space-y-4">
|
|
@@ -720,7 +783,7 @@ export const AIAssistant: React.FC = () => {
|
|
| 720 |
);
|
| 721 |
}
|
| 722 |
|
| 723 |
-
//
|
| 724 |
return (
|
| 725 |
<div className="h-full flex flex-col bg-slate-50 overflow-hidden relative">
|
| 726 |
{toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
|
|
|
|
| 2 |
import React, { useState, useRef, useEffect } from 'react';
|
| 3 |
import { api } from '../services/api';
|
| 4 |
import { AIChatMessage, SystemConfig, UserRole, OpenRouterModelConfig } from '../types';
|
| 5 |
+
import { Bot, Mic, Square, Play, Volume2, Send, CheckCircle, Brain, Sparkles, Loader2, StopCircle, Trash2, Image as ImageIcon, X, AlertTriangle, ShieldCheck, Activity, Power, ExternalLink, Key, Plus, Save, ArrowUp, ArrowDown, BarChart2 } from 'lucide-react';
|
| 6 |
import ReactMarkdown from 'react-markdown';
|
| 7 |
import remarkGfm from 'remark-gfm';
|
| 8 |
import { Toast, ToastState } from '../components/Toast';
|
| 9 |
+
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts';
|
| 10 |
|
| 11 |
// Utility to handle Base64 conversion
|
| 12 |
const blobToBase64 = (blob: Blob): Promise<string> => {
|
|
|
|
| 93 |
{ id: 'tngtech/deepseek-r1t-chimera:free', name: 'DeepSeek R1T', isCustom: false }
|
| 94 |
];
|
| 95 |
|
| 96 |
+
// Colors for Pie Chart
|
| 97 |
+
const PIE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4'];
|
| 98 |
+
|
| 99 |
export const AIAssistant: React.FC = () => {
|
| 100 |
const currentUser = api.auth.getCurrentUser();
|
| 101 |
const isAdmin = currentUser?.role === UserRole.ADMIN;
|
|
|
|
| 104 |
const [systemConfig, setSystemConfig] = useState<SystemConfig | null>(null);
|
| 105 |
const [configLoading, setConfigLoading] = useState(true);
|
| 106 |
const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
|
| 107 |
+
|
| 108 |
+
// Admin Stats State
|
| 109 |
+
const [detailedStats, setDetailedStats] = useState<{
|
| 110 |
+
totalCalls: number;
|
| 111 |
+
todayCount: number;
|
| 112 |
+
dailyTrend: {date: string, count: number}[];
|
| 113 |
+
modelDistribution: {name: string, value: number}[];
|
| 114 |
+
} | null>(null);
|
| 115 |
|
| 116 |
// Chat State with Persistence
|
| 117 |
const [activeTab, setActiveTab] = useState<'chat' | 'assessment'>('chat');
|
|
|
|
| 172 |
|
| 173 |
const cfg = await api.config.get();
|
| 174 |
setSystemConfig(cfg);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
if (isAdmin) {
|
| 176 |
+
if (cfg.apiKeys) {
|
| 177 |
+
setGeminiKeys(cfg.apiKeys.gemini || []);
|
| 178 |
+
setOpenRouterKeys(cfg.apiKeys.openrouter || []);
|
| 179 |
+
}
|
| 180 |
setOrModels(cfg.openRouterModels && cfg.openRouterModels.length > 0 ? cfg.openRouterModels : DEFAULT_OR_MODELS);
|
| 181 |
+
|
| 182 |
+
// Fetch Detailed Stats
|
| 183 |
+
const stats = await api.ai.getStats();
|
| 184 |
+
setDetailedStats(stats);
|
| 185 |
}
|
| 186 |
} catch (e) {
|
| 187 |
console.error("Init failed", e);
|
|
|
|
| 573 |
</div>
|
| 574 |
|
| 575 |
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 576 |
+
{/* STATS PANEL */}
|
| 577 |
+
<div className="bg-white p-6 rounded-xl border border-gray-100 shadow-sm md:col-span-2">
|
| 578 |
+
<h3 className="font-bold text-gray-800 mb-6 flex items-center"><Activity className="mr-2 text-purple-500"/> 调用数据分析</h3>
|
| 579 |
+
|
| 580 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
| 581 |
+
<div className="bg-purple-50 p-4 rounded-xl text-center">
|
| 582 |
+
<div className="text-3xl font-black text-purple-600">{detailedStats?.totalCalls || 0}</div>
|
| 583 |
+
<div className="text-xs text-purple-700 font-bold uppercase mt-1">历史累计调用</div>
|
| 584 |
</div>
|
| 585 |
+
<div className="bg-blue-50 p-4 rounded-xl text-center">
|
| 586 |
+
<div className="text-3xl font-black text-blue-600">{detailedStats?.todayCount || 0}</div>
|
| 587 |
+
<div className="text-xs text-blue-700 font-bold uppercase mt-1">今日调用次数</div>
|
| 588 |
+
</div>
|
| 589 |
+
<div className="md:col-span-2 bg-gray-50 p-4 rounded-xl flex items-center justify-center text-gray-500 text-sm">
|
| 590 |
+
<span className="mr-2 font-bold">当前状态:</span>
|
| 591 |
+
{systemConfig?.enableAI ? <span className="text-green-600 font-bold flex items-center"><CheckCircle size={14} className="mr-1"/> 服务运行中</span> : <span className="text-red-500 font-bold flex items-center"><X size={14} className="mr-1"/> 服务已暂停</span>}
|
| 592 |
+
</div>
|
| 593 |
+
</div>
|
| 594 |
+
|
| 595 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 596 |
+
{/* Weekly Trend Chart */}
|
| 597 |
+
<div className="h-64">
|
| 598 |
+
<h4 className="text-sm font-bold text-gray-600 mb-4 flex items-center"><BarChart2 size={16} className="mr-2"/> 近7日调用趋势</h4>
|
| 599 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 600 |
+
<BarChart data={detailedStats?.dailyTrend || []}>
|
| 601 |
+
<CartesianGrid strokeDasharray="3 3" vertical={false}/>
|
| 602 |
+
<XAxis dataKey="date" tick={{fontSize: 10}} tickFormatter={(val) => val.slice(5)}/>
|
| 603 |
+
<YAxis allowDecimals={false}/>
|
| 604 |
+
<Tooltip cursor={{fill: 'transparent'}} contentStyle={{borderRadius: '8px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.1)'}}/>
|
| 605 |
+
<Bar dataKey="count" fill="#8b5cf6" radius={[4, 4, 0, 0]} name="调用次数" />
|
| 606 |
+
</BarChart>
|
| 607 |
+
</ResponsiveContainer>
|
| 608 |
+
</div>
|
| 609 |
+
|
| 610 |
+
{/* Model Distribution Pie */}
|
| 611 |
+
<div className="h-64">
|
| 612 |
+
<h4 className="text-sm font-bold text-gray-600 mb-4 flex items-center"><Brain size={16} className="mr-2"/> 模型使用分布</h4>
|
| 613 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 614 |
+
<PieChart>
|
| 615 |
+
<Pie
|
| 616 |
+
data={detailedStats?.modelDistribution || []}
|
| 617 |
+
cx="50%"
|
| 618 |
+
cy="50%"
|
| 619 |
+
innerRadius={60}
|
| 620 |
+
outerRadius={80}
|
| 621 |
+
paddingAngle={5}
|
| 622 |
+
dataKey="value"
|
| 623 |
+
>
|
| 624 |
+
{(detailedStats?.modelDistribution || []).map((entry, index) => (
|
| 625 |
+
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
| 626 |
+
))}
|
| 627 |
+
</Pie>
|
| 628 |
+
<Tooltip />
|
| 629 |
+
<Legend layout="vertical" verticalAlign="middle" align="right" wrapperStyle={{fontSize: '10px'}}/>
|
| 630 |
+
</PieChart>
|
| 631 |
+
</ResponsiveContainer>
|
| 632 |
</div>
|
| 633 |
</div>
|
| 634 |
</div>
|
|
|
|
| 769 |
);
|
| 770 |
}
|
| 771 |
|
| 772 |
+
// ... (Rest of component remains same)
|
| 773 |
+
// --- MAINTENANCE MODE VIEW ---
|
| 774 |
if (!systemConfig?.enableAI) {
|
| 775 |
return (
|
| 776 |
<div className="flex flex-col items-center justify-center h-full text-center p-6 space-y-4">
|
|
|
|
| 783 |
);
|
| 784 |
}
|
| 785 |
|
| 786 |
+
// ... (Teacher View - Chat UI - Same as before)
|
| 787 |
return (
|
| 788 |
<div className="h-full flex flex-col bg-slate-50 overflow-hidden relative">
|
| 789 |
{toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
|
services/api.ts
CHANGED
|
@@ -271,7 +271,8 @@ export const api = {
|
|
| 271 |
ai: {
|
| 272 |
chat: (data: { text?: string, audio?: string, history?: { role: string, text?: string }[] }) => request('/ai/chat', { method: 'POST', body: JSON.stringify(data) }),
|
| 273 |
evaluate: (data: { question: string, audio?: string, image?: string }) => request('/ai/evaluate', { method: 'POST', body: JSON.stringify(data) }),
|
| 274 |
-
resetPool: () => request('/ai/reset-pool', { method: 'POST' }),
|
|
|
|
| 275 |
},
|
| 276 |
|
| 277 |
todos: { // NEW
|
|
|
|
| 271 |
ai: {
|
| 272 |
chat: (data: { text?: string, audio?: string, history?: { role: string, text?: string }[] }) => request('/ai/chat', { method: 'POST', body: JSON.stringify(data) }),
|
| 273 |
evaluate: (data: { question: string, audio?: string, image?: string }) => request('/ai/evaluate', { method: 'POST', body: JSON.stringify(data) }),
|
| 274 |
+
resetPool: () => request('/ai/reset-pool', { method: 'POST' }),
|
| 275 |
+
getStats: () => request('/ai/stats'), // NEW Detailed Stats
|
| 276 |
},
|
| 277 |
|
| 278 |
todos: { // NEW
|