Spaces:
Sleeping
Sleeping
Upload 67 files
Browse files- ai-routes.js +180 -108
- components/ai/ChatPanel.tsx +61 -21
- components/ai/WorkAssistantPanel.tsx +15 -15
- models.js +7 -4
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 axios = require('axios'); // Imported Axios for Doubao Context
|
| 6 |
const { ConfigModel, User, AIUsageModel, ChatHistoryModel } = require('./models');
|
| 7 |
const { buildUserContext } = require('./ai-context');
|
| 8 |
|
|
@@ -76,9 +76,49 @@ function convertGeminiToOpenAI(baseParams) {
|
|
| 76 |
return messages;
|
| 77 |
}
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
const PROVIDERS = { GEMINI: 'GEMINI', OPENROUTER: 'OPENROUTER', GEMMA: 'GEMMA', DOUBAO: 'DOUBAO' };
|
| 80 |
const DEFAULT_OPENROUTER_MODELS = ['qwen/qwen3-coder:free', 'openai/gpt-oss-120b:free', 'qwen/qwen3-235b-a22b:free', 'tngtech/deepseek-r1t-chimera:free'];
|
| 81 |
-
// No hardcoded default endpoint because it varies per user
|
| 82 |
|
| 83 |
// Runtime override logic
|
| 84 |
let runtimeProviderOrder = [];
|
|
@@ -109,11 +149,9 @@ async function streamGemini(baseParams, res, enableThinking = false) {
|
|
| 109 |
try {
|
| 110 |
console.log(`[AI] 🚀 Attempting Gemini Model: ${modelName} (Key ends with ...${apiKey.slice(-4)})`);
|
| 111 |
|
| 112 |
-
// Add thinking config if requested
|
| 113 |
const requestParams = { ...baseParams, model: modelName };
|
| 114 |
if (enableThinking) {
|
| 115 |
requestParams.config = requestParams.config || {};
|
| 116 |
-
// Gemini 2.5 Flash supports thinking config (set budget)
|
| 117 |
requestParams.config.thinkingConfig = { thinkingBudget: 1024 };
|
| 118 |
}
|
| 119 |
|
|
@@ -128,11 +166,6 @@ async function streamGemini(baseParams, res, enableThinking = false) {
|
|
| 128 |
recordUsage(modelName, PROVIDERS.GEMINI);
|
| 129 |
hasStarted = true;
|
| 130 |
}
|
| 131 |
-
|
| 132 |
-
// Check for thought content (provider specific, Gemini SDK usually keeps it in candidates)
|
| 133 |
-
// Note: Current Google GenAI Node SDK might not separate thought perfectly in stream without checking complex response structure.
|
| 134 |
-
// For now, we assume Gemini just streams text. If Gemini adds explicit thought parts in future SDKs, we parse here.
|
| 135 |
-
|
| 136 |
if (chunk.text) {
|
| 137 |
fullText += chunk.text;
|
| 138 |
res.write(`data: ${JSON.stringify({ type: 'text', content: chunk.text })}\n\n`);
|
|
@@ -153,46 +186,86 @@ async function streamGemini(baseParams, res, enableThinking = false) {
|
|
| 153 |
throw new Error("Gemini streaming failed (All keys/models exhausted)");
|
| 154 |
}
|
| 155 |
|
| 156 |
-
// --- DOUBAO DIRECT STREAMING (
|
| 157 |
-
|
| 158 |
-
async function streamDoubao(baseParams, res, userId, mode = 'chat', config, enableThinking = false) {
|
| 159 |
const keys = await getKeyPool('doubao');
|
| 160 |
if (keys.length === 0) throw new Error("No Doubao API keys configured");
|
| 161 |
-
|
| 162 |
-
// Convert to OpenAI format
|
| 163 |
-
const messages = convertGeminiToOpenAI(baseParams);
|
| 164 |
-
if (messages.length === 0) throw new Error("Doubao: Empty messages");
|
| 165 |
|
| 166 |
-
// DETERMINE ENDPOINT
|
| 167 |
if (!config || !config.doubaoModels || config.doubaoModels.length === 0) {
|
| 168 |
throw new Error("Doubao requires an Endpoint ID configured in Admin Panel.");
|
| 169 |
}
|
| 170 |
const doubaoConfig = config.doubaoModels[0];
|
| 171 |
-
const endpointId = doubaoConfig.endpointId;
|
| 172 |
const modelId = doubaoConfig.modelId || 'Doubao';
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
for (const apiKey of keys) {
|
| 175 |
try {
|
| 176 |
-
console.log(`[AI] 🚀 Calling Doubao API (Axios): ${endpointId}, Thinking: ${enableThinking}`);
|
| 177 |
-
|
| 178 |
-
// Payload matching user's CURL example
|
| 179 |
-
const payload = {
|
| 180 |
-
model: endpointId,
|
| 181 |
-
messages: messages,
|
| 182 |
-
stream: true,
|
| 183 |
-
thinking: { type: enableThinking ? "enabled" : "disabled" }
|
| 184 |
-
};
|
| 185 |
-
|
| 186 |
const response = await axios.post(
|
| 187 |
-
'https://ark.cn-beijing.volces.com/api/v3/
|
| 188 |
-
|
| 189 |
{
|
| 190 |
headers: {
|
| 191 |
'Content-Type': 'application/json',
|
| 192 |
'Authorization': `Bearer ${apiKey}`
|
| 193 |
},
|
| 194 |
responseType: 'stream',
|
| 195 |
-
timeout: 60000
|
| 196 |
}
|
| 197 |
);
|
| 198 |
|
|
@@ -200,19 +273,18 @@ async function streamDoubao(baseParams, res, userId, mode = 'chat', config, enab
|
|
| 200 |
recordUsage(modelId, PROVIDERS.DOUBAO);
|
| 201 |
|
| 202 |
let fullText = "";
|
| 203 |
-
let
|
| 204 |
let hasStarted = false;
|
| 205 |
let buffer = "";
|
| 206 |
|
| 207 |
const stream = response.data;
|
| 208 |
|
| 209 |
-
// Handle Node.js stream
|
| 210 |
for await (const chunk of stream) {
|
| 211 |
if (!hasStarted) hasStarted = true;
|
| 212 |
|
| 213 |
buffer += chunk.toString();
|
| 214 |
const lines = buffer.split('\n');
|
| 215 |
-
buffer = lines.pop();
|
| 216 |
|
| 217 |
for (const line of lines) {
|
| 218 |
const trimmed = line.trim();
|
|
@@ -222,29 +294,71 @@ async function streamDoubao(baseParams, res, userId, mode = 'chat', config, enab
|
|
| 222 |
try {
|
| 223 |
const jsonStr = trimmed.substring(6);
|
| 224 |
const json = JSON.parse(jsonStr);
|
| 225 |
-
const delta = json.choices?.[0]?.delta;
|
| 226 |
|
| 227 |
-
//
|
| 228 |
-
|
| 229 |
-
|
|
|
|
| 230 |
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
res.write(`data: ${JSON.stringify({ type: 'thinking', content:
|
| 234 |
if (res.flush) res.flush();
|
| 235 |
}
|
| 236 |
|
| 237 |
-
if (
|
|
|
|
| 238 |
fullText += content;
|
| 239 |
res.write(`data: ${JSON.stringify({ type: 'text', content: content })}\n\n`);
|
| 240 |
if (res.flush) res.flush();
|
| 241 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
} catch (e) {
|
| 243 |
-
// Ignore parse errors for partial chunks
|
| 244 |
}
|
| 245 |
}
|
| 246 |
}
|
| 247 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
return fullText;
|
| 250 |
|
|
@@ -254,6 +368,16 @@ async function streamDoubao(baseParams, res, userId, mode = 'chat', config, enab
|
|
| 254 |
console.log(`[AI] 🔄 Quota exceeded, trying next key...`);
|
| 255 |
continue;
|
| 256 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
throw e;
|
| 258 |
}
|
| 259 |
}
|
|
@@ -279,22 +403,13 @@ async function streamOpenRouter(baseParams, res) {
|
|
| 279 |
|
| 280 |
const client = new OpenAI({ baseURL, apiKey, defaultHeaders: { "HTTP-Referer": "https://smart.com", "X-Title": "Smart School" } });
|
| 281 |
|
| 282 |
-
const extraBody = {};
|
| 283 |
-
// If user uses Doubao via OpenRouter/Custom Proxy, also try to apply cache/thinking params
|
| 284 |
-
if (modelName.toLowerCase().includes('doubao')) {
|
| 285 |
-
extraBody.caching = { type: "enabled", prefix: true };
|
| 286 |
-
// We rely on the request passing thinking enabled, but OpenRouter implementation might vary
|
| 287 |
-
// For now, standard OpenRouter doesn't support 'thinking' param standardly, mostly handled via model choice (e.g. R1)
|
| 288 |
-
}
|
| 289 |
-
|
| 290 |
try {
|
| 291 |
-
console.log(`[AI] 🚀 Attempting ${providerLabel} Model: ${modelName}
|
| 292 |
|
| 293 |
const stream = await client.chat.completions.create({
|
| 294 |
model: modelName,
|
| 295 |
messages,
|
| 296 |
-
stream: true
|
| 297 |
-
...extraBody
|
| 298 |
});
|
| 299 |
|
| 300 |
console.log(`[AI] ✅ Connected to ${providerLabel}: ${modelName}`);
|
|
@@ -305,7 +420,6 @@ async function streamOpenRouter(baseParams, res) {
|
|
| 305 |
const text = chunk.choices[0]?.delta?.content || '';
|
| 306 |
if (text) {
|
| 307 |
fullText += text;
|
| 308 |
-
// FIX: Use { type: 'text', content: ... }
|
| 309 |
res.write(`data: ${JSON.stringify({ type: 'text', content: text })}\n\n`);
|
| 310 |
if (res.flush) res.flush();
|
| 311 |
}
|
|
@@ -313,14 +427,11 @@ async function streamOpenRouter(baseParams, res) {
|
|
| 313 |
return fullText;
|
| 314 |
} catch (e) {
|
| 315 |
console.warn(`[AI] ⚠️ ${providerLabel} ${modelName} Error: ${e.message}`);
|
| 316 |
-
if (isQuotaError(e))
|
| 317 |
-
console.log(`[AI] 🔄 Rate limit/Quota for ${modelName}, switching...`);
|
| 318 |
-
break;
|
| 319 |
-
}
|
| 320 |
}
|
| 321 |
}
|
| 322 |
}
|
| 323 |
-
throw new Error("OpenRouter
|
| 324 |
}
|
| 325 |
|
| 326 |
async function streamGemma(baseParams, res) {
|
|
@@ -346,7 +457,6 @@ async function streamGemma(baseParams, res) {
|
|
| 346 |
}
|
| 347 |
if (chunk.text) {
|
| 348 |
fullText += chunk.text;
|
| 349 |
-
// FIX: Use { type: 'text', content: ... }
|
| 350 |
res.write(`data: ${JSON.stringify({ type: 'text', content: chunk.text })}\n\n`);
|
| 351 |
if (res.flush) res.flush();
|
| 352 |
}
|
|
@@ -361,7 +471,7 @@ async function streamGemma(baseParams, res) {
|
|
| 361 |
throw new Error("Gemma stream failed");
|
| 362 |
}
|
| 363 |
|
| 364 |
-
async function streamContentWithSmartFallback(baseParams, res,
|
| 365 |
let hasAudio = false;
|
| 366 |
const contentsArray = Array.isArray(baseParams.contents) ? baseParams.contents : [baseParams.contents];
|
| 367 |
|
|
@@ -395,10 +505,10 @@ async function streamContentWithSmartFallback(baseParams, res, userId, mode = 'c
|
|
| 395 |
let finalError = null;
|
| 396 |
for (const provider of runtimeProviderOrder) {
|
| 397 |
try {
|
| 398 |
-
console.log(`[AI] 👉 Trying Provider: ${provider}... Mode: ${mode}, Thinking: ${enableThinking}`);
|
| 399 |
if (provider === PROVIDERS.GEMINI) return await streamGemini(baseParams, res, enableThinking);
|
| 400 |
else if (provider === PROVIDERS.OPENROUTER) return await streamOpenRouter(baseParams, res);
|
| 401 |
-
else if (provider === PROVIDERS.DOUBAO) return await streamDoubao(baseParams, res,
|
| 402 |
else if (provider === PROVIDERS.GEMMA) return await streamGemma(baseParams, res);
|
| 403 |
} catch (e) {
|
| 404 |
console.error(`[AI] ❌ Provider ${provider} Failed: ${e.message}`);
|
|
@@ -457,20 +567,16 @@ router.post('/reset-pool', checkAIAccess, (req, res) => {
|
|
| 457 |
console.log('[AI] 🔄 Provider priority pool reset.');
|
| 458 |
res.json({ success: true });
|
| 459 |
});
|
| 460 |
-
|
| 461 |
-
// Instead of relying on client-side 'history', we use MongoDB to ensure cross-device memory.
|
| 462 |
router.post('/chat', async (req, res) => {
|
| 463 |
-
const { text, audio, images, history, enableThinking, overrideSystemPrompt } = req.body;
|
| 464 |
const userRole = req.headers['x-user-role'];
|
| 465 |
const username = req.headers['x-user-username'];
|
| 466 |
const schoolId = req.headers['x-school-id'];
|
| 467 |
|
| 468 |
-
// ... (
|
| 469 |
-
// If overrideSystemPrompt is provided (Work Assistant), use it. Otherwise build standard context.
|
| 470 |
const systemInstruction = overrideSystemPrompt || await buildUserContext(username, userRole, schoolId);
|
| 471 |
|
| 472 |
-
// Build History
|
| 473 |
-
// Filter out messages with empty text to prevent API errors
|
| 474 |
const geminiHistory = (history || [])
|
| 475 |
.filter(msg => msg.text && msg.text.trim() !== '')
|
| 476 |
.map(msg => ({
|
|
@@ -478,23 +584,15 @@ router.post('/chat', async (req, res) => {
|
|
| 478 |
parts: [{ text: msg.text }]
|
| 479 |
}));
|
| 480 |
|
| 481 |
-
// Build Current Message Parts
|
| 482 |
const currentParts = [];
|
| 483 |
-
|
| 484 |
-
// 1. Text Instruction (Priority)
|
| 485 |
-
// FIX: Put text FIRST to ensure model pays attention to instructions before processing media
|
| 486 |
if (text && text.trim()) {
|
| 487 |
currentParts.push({ text: text });
|
| 488 |
} else if (audio) {
|
| 489 |
-
// Fix: Explicit instruction for audio, placed before audio data if possible
|
| 490 |
currentParts.push({ text: "用户发送了语音消息,请听录音并回答。" });
|
| 491 |
} else {
|
| 492 |
-
// Fallback
|
| 493 |
currentParts.push({ text: "." });
|
| 494 |
}
|
| 495 |
|
| 496 |
-
// 2. Media (Images/Audio)
|
| 497 |
-
// Add Images
|
| 498 |
if (images && Array.isArray(images)) {
|
| 499 |
images.forEach(base64 => {
|
| 500 |
if (base64) {
|
|
@@ -508,7 +606,6 @@ router.post('/chat', async (req, res) => {
|
|
| 508 |
});
|
| 509 |
}
|
| 510 |
|
| 511 |
-
// Add Audio
|
| 512 |
if (audio) {
|
| 513 |
currentParts.push({
|
| 514 |
inlineData: {
|
|
@@ -518,7 +615,6 @@ router.post('/chat', async (req, res) => {
|
|
| 518 |
});
|
| 519 |
}
|
| 520 |
|
| 521 |
-
// Prepare Response Stream
|
| 522 |
res.setHeader('Content-Type', 'text/event-stream');
|
| 523 |
res.setHeader('Cache-Control', 'no-cache');
|
| 524 |
res.setHeader('Connection', 'keep-alive');
|
|
@@ -527,18 +623,13 @@ router.post('/chat', async (req, res) => {
|
|
| 527 |
let fullText = "";
|
| 528 |
|
| 529 |
try {
|
| 530 |
-
// 1. Generate Text (Stream)
|
| 531 |
-
// FIX: Place systemInstruction inside `config` object to comply with Gemini SDK and ensure visibility for Doubao conversion
|
| 532 |
fullText = await streamContentWithSmartFallback({
|
| 533 |
contents: [...geminiHistory, { role: 'user', parts: currentParts }],
|
| 534 |
config: {
|
| 535 |
systemInstruction: systemInstruction
|
| 536 |
}
|
| 537 |
-
}, res, req.headers['x-user-username'], 'chat', enableThinking);
|
| 538 |
|
| 539 |
-
// 2. Save User Message to DB
|
| 540 |
-
// For Work Assistant, we might want to segregate history, but for simplicity we save all to same collection
|
| 541 |
-
// Client-side can filter or use distinct states
|
| 542 |
await ChatHistoryModel.create({
|
| 543 |
userId: req.headers['x-user-username'],
|
| 544 |
role: 'user',
|
|
@@ -546,9 +637,6 @@ router.post('/chat', async (req, res) => {
|
|
| 546 |
timestamp: Date.now()
|
| 547 |
});
|
| 548 |
|
| 549 |
-
// 3. Generate TTS (Independent of Text Model)
|
| 550 |
-
// Only generate audio if text is sufficient length, not just punctuation, AND NO Thinking logic (Work Assistant disables audio usually)
|
| 551 |
-
// Check req.body.disableAudio which might be passed by Work Assistant
|
| 552 |
if (fullText && fullText.length > 2 && !req.body.disableAudio) {
|
| 553 |
res.write(`data: ${JSON.stringify({ type: 'status', status: 'tts' })}\n\n`);
|
| 554 |
try {
|
|
@@ -556,13 +644,12 @@ router.post('/chat', async (req, res) => {
|
|
| 556 |
const keys = await getKeyPool('gemini');
|
| 557 |
let audioData = null;
|
| 558 |
|
| 559 |
-
// Retry logic for TTS
|
| 560 |
for (const apiKey of keys) {
|
| 561 |
try {
|
| 562 |
const client = new GoogleGenAI({ apiKey });
|
| 563 |
const ttsResponse = await client.models.generateContent({
|
| 564 |
model: "gemini-2.5-flash-preview-tts",
|
| 565 |
-
contents: [{ parts: [{ text: fullText.substring(0, 500) }] }],
|
| 566 |
config: { responseModalities: ['AUDIO'], speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } } }
|
| 567 |
});
|
| 568 |
audioData = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
|
|
@@ -576,16 +663,12 @@ router.post('/chat', async (req, res) => {
|
|
| 576 |
res.write(`data: ${JSON.stringify({ type: 'status', ttsSkipped: true })}\n\n`);
|
| 577 |
}
|
| 578 |
} catch (ttsErr) {
|
| 579 |
-
console.error("TTS Error:", ttsErr);
|
| 580 |
res.write(`data: ${JSON.stringify({ type: 'status', ttsSkipped: true })}\n\n`);
|
| 581 |
}
|
| 582 |
} else {
|
| 583 |
res.write(`data: ${JSON.stringify({ type: 'status', ttsSkipped: true })}\n\n`);
|
| 584 |
}
|
| 585 |
|
| 586 |
-
// 4. Save Model Message to DB
|
| 587 |
-
// If we have audio, we could technically save it, but MongoDB limits document size.
|
| 588 |
-
// For now, we rely on text history and client-side TTS fallback if audio is lost on refresh.
|
| 589 |
await ChatHistoryModel.create({
|
| 590 |
userId: req.headers['x-user-username'],
|
| 591 |
role: 'model',
|
|
@@ -605,11 +688,8 @@ router.post('/chat', async (req, res) => {
|
|
| 605 |
}
|
| 606 |
});
|
| 607 |
|
| 608 |
-
// STREAMING ASSESSMENT ENDPOINT
|
| 609 |
router.post('/evaluate', checkAIAccess, async (req, res) => {
|
| 610 |
const { question, audio, image, images } = req.body;
|
| 611 |
-
|
| 612 |
-
// Extract User info for userId passing
|
| 613 |
const username = req.headers['x-user-username'];
|
| 614 |
const user = await User.findOne({ username });
|
| 615 |
|
|
@@ -627,19 +707,16 @@ router.post('/evaluate', checkAIAccess, async (req, res) => {
|
|
| 627 |
evalParts.push({ inlineData: { mimeType: 'audio/webm', data: audio } });
|
| 628 |
}
|
| 629 |
|
| 630 |
-
// Support multiple images
|
| 631 |
if (images && Array.isArray(images) && images.length > 0) {
|
| 632 |
evalParts.push({ text: "学生的回答写在以下图片中,请识别所有图片中的文字内容并进行批改:" });
|
| 633 |
images.forEach(img => {
|
| 634 |
if(img) evalParts.push({ inlineData: { mimeType: 'image/jpeg', data: img } });
|
| 635 |
});
|
| 636 |
} else if (image) {
|
| 637 |
-
// Legacy single image support
|
| 638 |
evalParts.push({ text: "学生的回答写在图片中,请识别图片中的文字内容并进行批改。" });
|
| 639 |
evalParts.push({ inlineData: { mimeType: 'image/jpeg', data: image } });
|
| 640 |
}
|
| 641 |
|
| 642 |
-
// Force structured markdown output for streaming parsing
|
| 643 |
evalParts.push({ text: `请分析:1. 内容准确性 2. 表达/书写规范。
|
| 644 |
必须严格按照以下格式输出(不要使用Markdown代码块包裹):
|
| 645 |
|
|
@@ -652,18 +729,13 @@ router.post('/evaluate', checkAIAccess, async (req, res) => {
|
|
| 652 |
## Score
|
| 653 |
(在此处仅输出一个0-100的数字)` });
|
| 654 |
|
| 655 |
-
// Stream Text
|
| 656 |
const fullText = await streamContentWithSmartFallback({
|
| 657 |
-
// CRITICAL FIX: Pass as array of objects for OpenRouter compatibility
|
| 658 |
contents: [{ role: 'user', parts: evalParts }],
|
| 659 |
-
|
| 660 |
-
}, res, user?._id, 'evaluate'); // Pass mode='evaluate'
|
| 661 |
|
| 662 |
-
// Extract Feedback for TTS
|
| 663 |
const feedbackMatch = fullText.match(/## Feedback\s+([\s\S]*?)(?=## Score|$)/i);
|
| 664 |
const feedbackText = feedbackMatch ? feedbackMatch[1].trim() : "";
|
| 665 |
|
| 666 |
-
// Generate TTS if feedback exists
|
| 667 |
if (feedbackText) {
|
| 668 |
res.write(`data: ${JSON.stringify({ status: 'tts' })}\n\n`);
|
| 669 |
try {
|
|
|
|
| 2 |
const express = require('express');
|
| 3 |
const router = express.Router();
|
| 4 |
const OpenAI = require('openai');
|
| 5 |
+
const axios = require('axios'); // Imported Axios for Doubao Context
|
| 6 |
const { ConfigModel, User, AIUsageModel, ChatHistoryModel } = require('./models');
|
| 7 |
const { buildUserContext } = require('./ai-context');
|
| 8 |
|
|
|
|
| 76 |
return messages;
|
| 77 |
}
|
| 78 |
|
| 79 |
+
// Convert Gemini params to Doubao Native Input Format
|
| 80 |
+
function convertGeminiToDoubaoInput(baseParams) {
|
| 81 |
+
const inputs = [];
|
| 82 |
+
|
| 83 |
+
// 1. System Prompt
|
| 84 |
+
const sysInstruction = baseParams.config?.systemInstruction || baseParams.systemInstruction;
|
| 85 |
+
if (sysInstruction) {
|
| 86 |
+
inputs.push({ role: 'system', content: [{ type: 'text', text: sysInstruction }] });
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// 2. Chat History & Current Message
|
| 90 |
+
let contents = baseParams.contents;
|
| 91 |
+
if (contents && !Array.isArray(contents)) {
|
| 92 |
+
contents = [contents];
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
if (contents) {
|
| 96 |
+
contents.forEach(content => {
|
| 97 |
+
let role = (content.role === 'model' || content.role === 'assistant') ? 'assistant' : 'user';
|
| 98 |
+
const contentParts = [];
|
| 99 |
+
|
| 100 |
+
if (content.parts) {
|
| 101 |
+
content.parts.forEach(p => {
|
| 102 |
+
if (p.text) {
|
| 103 |
+
// SPEC: User text must be "input_text", Assistant must be "text"
|
| 104 |
+
const type = role === 'user' ? 'input_text' : 'text';
|
| 105 |
+
contentParts.push({ type: type, text: p.text });
|
| 106 |
+
}
|
| 107 |
+
else if (p.inlineData && p.inlineData.mimeType.startsWith('image/')) {
|
| 108 |
+
contentParts.push({ type: 'image_url', image_url: { url: `data:${p.inlineData.mimeType};base64,${p.inlineData.data}` } });
|
| 109 |
+
}
|
| 110 |
+
});
|
| 111 |
+
}
|
| 112 |
+
if (contentParts.length > 0) {
|
| 113 |
+
inputs.push({ role: role, content: contentParts });
|
| 114 |
+
}
|
| 115 |
+
});
|
| 116 |
+
}
|
| 117 |
+
return inputs;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
const PROVIDERS = { GEMINI: 'GEMINI', OPENROUTER: 'OPENROUTER', GEMMA: 'GEMMA', DOUBAO: 'DOUBAO' };
|
| 121 |
const DEFAULT_OPENROUTER_MODELS = ['qwen/qwen3-coder:free', 'openai/gpt-oss-120b:free', 'qwen/qwen3-235b-a22b:free', 'tngtech/deepseek-r1t-chimera:free'];
|
|
|
|
| 122 |
|
| 123 |
// Runtime override logic
|
| 124 |
let runtimeProviderOrder = [];
|
|
|
|
| 149 |
try {
|
| 150 |
console.log(`[AI] 🚀 Attempting Gemini Model: ${modelName} (Key ends with ...${apiKey.slice(-4)})`);
|
| 151 |
|
|
|
|
| 152 |
const requestParams = { ...baseParams, model: modelName };
|
| 153 |
if (enableThinking) {
|
| 154 |
requestParams.config = requestParams.config || {};
|
|
|
|
| 155 |
requestParams.config.thinkingConfig = { thinkingBudget: 1024 };
|
| 156 |
}
|
| 157 |
|
|
|
|
| 166 |
recordUsage(modelName, PROVIDERS.GEMINI);
|
| 167 |
hasStarted = true;
|
| 168 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
if (chunk.text) {
|
| 170 |
fullText += chunk.text;
|
| 171 |
res.write(`data: ${JSON.stringify({ type: 'text', content: chunk.text })}\n\n`);
|
|
|
|
| 186 |
throw new Error("Gemini streaming failed (All keys/models exhausted)");
|
| 187 |
}
|
| 188 |
|
| 189 |
+
// --- DOUBAO DIRECT STREAMING (Ark /responses) ---
|
| 190 |
+
async function streamDoubao(baseParams, res, username, mode = 'chat', config, enableThinking = false, enableSearch = false) {
|
|
|
|
| 191 |
const keys = await getKeyPool('doubao');
|
| 192 |
if (keys.length === 0) throw new Error("No Doubao API keys configured");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
+
// DETERMINE ENDPOINT & MODEL
|
| 195 |
if (!config || !config.doubaoModels || config.doubaoModels.length === 0) {
|
| 196 |
throw new Error("Doubao requires an Endpoint ID configured in Admin Panel.");
|
| 197 |
}
|
| 198 |
const doubaoConfig = config.doubaoModels[0];
|
| 199 |
+
const endpointId = doubaoConfig.endpointId;
|
| 200 |
const modelId = doubaoConfig.modelId || 'Doubao';
|
| 201 |
|
| 202 |
+
// --- CONTEXT LOGIC START ---
|
| 203 |
+
const user = await User.findOne({ username });
|
| 204 |
+
const lastId = user?.doubaoState?.responseId;
|
| 205 |
+
const lastThinking = user?.doubaoState?.thinkingState;
|
| 206 |
+
|
| 207 |
+
// 1. Decide on Caching
|
| 208 |
+
// Rule: Search disables cache. Otherwise enable it.
|
| 209 |
+
const requestCachingType = enableSearch ? "disabled" : "enabled";
|
| 210 |
+
|
| 211 |
+
// 2. Decide on ID Reuse
|
| 212 |
+
// Rule: Must have ID + No Search + Thinking State Unchanged
|
| 213 |
+
let idToSend = null;
|
| 214 |
+
// Note: 'undefined' matches 'false' effectively for our boolean logic check if we cast both
|
| 215 |
+
if (lastId && !enableSearch && (!!lastThinking === !!enableThinking)) {
|
| 216 |
+
idToSend = lastId;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// 3. Build Input
|
| 220 |
+
let inputPayload = [];
|
| 221 |
+
if (idToSend) {
|
| 222 |
+
// INCREMENTAL MODE: We assume the server has context.
|
| 223 |
+
// We need to send System Prompt + NEWEST Message only.
|
| 224 |
+
// However, 'convertGeminiToDoubaoInput' converts the whole baseParams list.
|
| 225 |
+
// We need to extract just the System and the last User message.
|
| 226 |
+
|
| 227 |
+
const fullInputs = convertGeminiToDoubaoInput(baseParams);
|
| 228 |
+
|
| 229 |
+
const systemMsg = fullInputs.find(m => m.role === 'system');
|
| 230 |
+
// Get the last message (User's new question)
|
| 231 |
+
// Gemini format 'contents' usually ends with the new prompt.
|
| 232 |
+
const lastMsg = fullInputs[fullInputs.length - 1];
|
| 233 |
+
|
| 234 |
+
if (systemMsg) inputPayload.push(systemMsg);
|
| 235 |
+
if (lastMsg) inputPayload.push(lastMsg);
|
| 236 |
+
} else {
|
| 237 |
+
// FULL MODE: ID Invalid/Missing/SearchOn -> Send Full History
|
| 238 |
+
inputPayload = convertGeminiToDoubaoInput(baseParams);
|
| 239 |
+
}
|
| 240 |
+
// --- CONTEXT LOGIC END ---
|
| 241 |
+
|
| 242 |
+
// Build Request Body
|
| 243 |
+
const requestBody = {
|
| 244 |
+
model: endpointId,
|
| 245 |
+
stream: true,
|
| 246 |
+
input: inputPayload,
|
| 247 |
+
thinking: { type: enableThinking ? "enabled" : "disabled" },
|
| 248 |
+
caching: { type: requestCachingType },
|
| 249 |
+
// Add ID if valid
|
| 250 |
+
...(idToSend && { previous_response_id: idToSend }),
|
| 251 |
+
// Add Search Tools if enabled
|
| 252 |
+
...(enableSearch && { tools: [{ type: "web_search", web_search: { search_result: true } }] })
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
console.log(`[AI] 🚀 Doubao Ark Request: EP=${endpointId}, Cache=${requestCachingType}, Think=${enableThinking}, Search=${enableSearch}, PrevID=${idToSend ? 'YES' : 'NO'}`);
|
| 256 |
+
|
| 257 |
for (const apiKey of keys) {
|
| 258 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
const response = await axios.post(
|
| 260 |
+
'https://ark.cn-beijing.volces.com/api/v3/responses',
|
| 261 |
+
requestBody,
|
| 262 |
{
|
| 263 |
headers: {
|
| 264 |
'Content-Type': 'application/json',
|
| 265 |
'Authorization': `Bearer ${apiKey}`
|
| 266 |
},
|
| 267 |
responseType: 'stream',
|
| 268 |
+
timeout: 60000
|
| 269 |
}
|
| 270 |
);
|
| 271 |
|
|
|
|
| 273 |
recordUsage(modelId, PROVIDERS.DOUBAO);
|
| 274 |
|
| 275 |
let fullText = "";
|
| 276 |
+
let newResponseID = null;
|
| 277 |
let hasStarted = false;
|
| 278 |
let buffer = "";
|
| 279 |
|
| 280 |
const stream = response.data;
|
| 281 |
|
|
|
|
| 282 |
for await (const chunk of stream) {
|
| 283 |
if (!hasStarted) hasStarted = true;
|
| 284 |
|
| 285 |
buffer += chunk.toString();
|
| 286 |
const lines = buffer.split('\n');
|
| 287 |
+
buffer = lines.pop();
|
| 288 |
|
| 289 |
for (const line of lines) {
|
| 290 |
const trimmed = line.trim();
|
|
|
|
| 294 |
try {
|
| 295 |
const jsonStr = trimmed.substring(6);
|
| 296 |
const json = JSON.parse(jsonStr);
|
|
|
|
| 297 |
|
| 298 |
+
// 1. Get Response ID (Anchor)
|
| 299 |
+
if (json.response && json.response.id) {
|
| 300 |
+
newResponseID = json.response.id;
|
| 301 |
+
}
|
| 302 |
|
| 303 |
+
// 2. Handle Content (Reasoning vs Output)
|
| 304 |
+
if (json.response && json.response.reasoning_text && json.response.reasoning_text.delta) {
|
| 305 |
+
res.write(`data: ${JSON.stringify({ type: 'thinking', content: json.response.reasoning_text.delta })}\n\n`);
|
| 306 |
if (res.flush) res.flush();
|
| 307 |
}
|
| 308 |
|
| 309 |
+
if (json.response && json.response.output_text && json.response.output_text.delta) {
|
| 310 |
+
const content = json.response.output_text.delta;
|
| 311 |
fullText += content;
|
| 312 |
res.write(`data: ${JSON.stringify({ type: 'text', content: content })}\n\n`);
|
| 313 |
if (res.flush) res.flush();
|
| 314 |
}
|
| 315 |
+
|
| 316 |
+
// 3. Handle Search Status
|
| 317 |
+
if (json.response && json.response.web_search_call && json.response.web_search_call.searching) {
|
| 318 |
+
// Send a status update to UI (formatted as text or custom type)
|
| 319 |
+
// We send a temporary thinking-like message or just custom type
|
| 320 |
+
res.write(`data: ${JSON.stringify({ type: 'thinking', content: '\n\n🌐 正在联网搜索...\n\n' })}\n\n`);
|
| 321 |
+
if (res.flush) res.flush();
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
// 4. Handle References (End of stream usually)
|
| 325 |
+
if (json.response && json.response.content_part && json.response.content_part.done) {
|
| 326 |
+
const annotations = json.response.content_part.annotations || [];
|
| 327 |
+
if (annotations.length > 0) {
|
| 328 |
+
let refText = "\n\n**引用来源:**\n";
|
| 329 |
+
annotations.forEach((ref, idx) => {
|
| 330 |
+
refText += `[${idx + 1}] [${ref.title}](${ref.url})\n`;
|
| 331 |
+
});
|
| 332 |
+
fullText += refText;
|
| 333 |
+
res.write(`data: ${JSON.stringify({ type: 'text', content: refText })}\n\n`);
|
| 334 |
+
if (res.flush) res.flush();
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
} catch (e) {
|
| 339 |
+
// Ignore parse errors for partial chunks
|
| 340 |
}
|
| 341 |
}
|
| 342 |
}
|
| 343 |
}
|
| 344 |
+
|
| 345 |
+
// 4. Update User State (Context Persistence)
|
| 346 |
+
// Rules: Only save ID if Search was OFF.
|
| 347 |
+
if (!enableSearch && newResponseID) {
|
| 348 |
+
await User.findOneAndUpdate({ username }, {
|
| 349 |
+
doubaoState: {
|
| 350 |
+
responseId: newResponseID,
|
| 351 |
+
thinkingState: enableThinking
|
| 352 |
+
}
|
| 353 |
+
});
|
| 354 |
+
console.log(`[AI] 💾 Doubao Context Updated: ID=${newResponseID}`);
|
| 355 |
+
} else {
|
| 356 |
+
// If search was ON, ID is invalid for next turn. Clear it to force full history next time.
|
| 357 |
+
await User.findOneAndUpdate({ username }, {
|
| 358 |
+
doubaoState: { responseId: null, thinkingState: null }
|
| 359 |
+
});
|
| 360 |
+
console.log(`[AI] 🧹 Doubao Context Cleared (Search Active or No ID)`);
|
| 361 |
+
}
|
| 362 |
|
| 363 |
return fullText;
|
| 364 |
|
|
|
|
| 368 |
console.log(`[AI] 🔄 Quota exceeded, trying next key...`);
|
| 369 |
continue;
|
| 370 |
}
|
| 371 |
+
// If error is 400 (Bad Request), it might be due to invalid ID.
|
| 372 |
+
// Retry ONCE with ID=null (Full History) if we tried with ID
|
| 373 |
+
if (e.response?.status === 400 && idToSend) {
|
| 374 |
+
console.log(`[AI] ⚠️ 400 Error with ID. Retrying with full history...`);
|
| 375 |
+
idToSend = null;
|
| 376 |
+
// Recursive retry with modified params (careful with infinite loops, handled by loop structure somewhat, but strictly we should just clear DB and fail this request gracefully or implement simpler retry)
|
| 377 |
+
await User.findOneAndUpdate({ username }, { doubaoState: { responseId: null } });
|
| 378 |
+
// We won't recurse here to keep it simple, just fail and let user retry (which will pick up null ID)
|
| 379 |
+
throw new Error("Context invalid. Please retry.");
|
| 380 |
+
}
|
| 381 |
throw e;
|
| 382 |
}
|
| 383 |
}
|
|
|
|
| 403 |
|
| 404 |
const client = new OpenAI({ baseURL, apiKey, defaultHeaders: { "HTTP-Referer": "https://smart.com", "X-Title": "Smart School" } });
|
| 405 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
try {
|
| 407 |
+
console.log(`[AI] 🚀 Attempting ${providerLabel} Model: ${modelName}`);
|
| 408 |
|
| 409 |
const stream = await client.chat.completions.create({
|
| 410 |
model: modelName,
|
| 411 |
messages,
|
| 412 |
+
stream: true
|
|
|
|
| 413 |
});
|
| 414 |
|
| 415 |
console.log(`[AI] ✅ Connected to ${providerLabel}: ${modelName}`);
|
|
|
|
| 420 |
const text = chunk.choices[0]?.delta?.content || '';
|
| 421 |
if (text) {
|
| 422 |
fullText += text;
|
|
|
|
| 423 |
res.write(`data: ${JSON.stringify({ type: 'text', content: text })}\n\n`);
|
| 424 |
if (res.flush) res.flush();
|
| 425 |
}
|
|
|
|
| 427 |
return fullText;
|
| 428 |
} catch (e) {
|
| 429 |
console.warn(`[AI] ⚠️ ${providerLabel} ${modelName} Error: ${e.message}`);
|
| 430 |
+
if (isQuotaError(e)) break;
|
|
|
|
|
|
|
|
|
|
| 431 |
}
|
| 432 |
}
|
| 433 |
}
|
| 434 |
+
throw new Error("OpenRouter stream failed");
|
| 435 |
}
|
| 436 |
|
| 437 |
async function streamGemma(baseParams, res) {
|
|
|
|
| 457 |
}
|
| 458 |
if (chunk.text) {
|
| 459 |
fullText += chunk.text;
|
|
|
|
| 460 |
res.write(`data: ${JSON.stringify({ type: 'text', content: chunk.text })}\n\n`);
|
| 461 |
if (res.flush) res.flush();
|
| 462 |
}
|
|
|
|
| 471 |
throw new Error("Gemma stream failed");
|
| 472 |
}
|
| 473 |
|
| 474 |
+
async function streamContentWithSmartFallback(baseParams, res, username, mode = 'chat', enableThinking = false, enableSearch = false) {
|
| 475 |
let hasAudio = false;
|
| 476 |
const contentsArray = Array.isArray(baseParams.contents) ? baseParams.contents : [baseParams.contents];
|
| 477 |
|
|
|
|
| 505 |
let finalError = null;
|
| 506 |
for (const provider of runtimeProviderOrder) {
|
| 507 |
try {
|
| 508 |
+
console.log(`[AI] 👉 Trying Provider: ${provider}... Mode: ${mode}, Thinking: ${enableThinking}, Search: ${enableSearch}`);
|
| 509 |
if (provider === PROVIDERS.GEMINI) return await streamGemini(baseParams, res, enableThinking);
|
| 510 |
else if (provider === PROVIDERS.OPENROUTER) return await streamOpenRouter(baseParams, res);
|
| 511 |
+
else if (provider === PROVIDERS.DOUBAO) return await streamDoubao(baseParams, res, username, mode, config, enableThinking, enableSearch);
|
| 512 |
else if (provider === PROVIDERS.GEMMA) return await streamGemma(baseParams, res);
|
| 513 |
} catch (e) {
|
| 514 |
console.error(`[AI] ❌ Provider ${provider} Failed: ${e.message}`);
|
|
|
|
| 567 |
console.log('[AI] 🔄 Provider priority pool reset.');
|
| 568 |
res.json({ success: true });
|
| 569 |
});
|
| 570 |
+
|
|
|
|
| 571 |
router.post('/chat', async (req, res) => {
|
| 572 |
+
const { text, audio, images, history, enableThinking, overrideSystemPrompt, enableSearch } = req.body;
|
| 573 |
const userRole = req.headers['x-user-role'];
|
| 574 |
const username = req.headers['x-user-username'];
|
| 575 |
const schoolId = req.headers['x-school-id'];
|
| 576 |
|
| 577 |
+
// ... (Context building logic)
|
|
|
|
| 578 |
const systemInstruction = overrideSystemPrompt || await buildUserContext(username, userRole, schoolId);
|
| 579 |
|
|
|
|
|
|
|
| 580 |
const geminiHistory = (history || [])
|
| 581 |
.filter(msg => msg.text && msg.text.trim() !== '')
|
| 582 |
.map(msg => ({
|
|
|
|
| 584 |
parts: [{ text: msg.text }]
|
| 585 |
}));
|
| 586 |
|
|
|
|
| 587 |
const currentParts = [];
|
|
|
|
|
|
|
|
|
|
| 588 |
if (text && text.trim()) {
|
| 589 |
currentParts.push({ text: text });
|
| 590 |
} else if (audio) {
|
|
|
|
| 591 |
currentParts.push({ text: "用户发送了语音消息,请听录音并回答。" });
|
| 592 |
} else {
|
|
|
|
| 593 |
currentParts.push({ text: "." });
|
| 594 |
}
|
| 595 |
|
|
|
|
|
|
|
| 596 |
if (images && Array.isArray(images)) {
|
| 597 |
images.forEach(base64 => {
|
| 598 |
if (base64) {
|
|
|
|
| 606 |
});
|
| 607 |
}
|
| 608 |
|
|
|
|
| 609 |
if (audio) {
|
| 610 |
currentParts.push({
|
| 611 |
inlineData: {
|
|
|
|
| 615 |
});
|
| 616 |
}
|
| 617 |
|
|
|
|
| 618 |
res.setHeader('Content-Type', 'text/event-stream');
|
| 619 |
res.setHeader('Cache-Control', 'no-cache');
|
| 620 |
res.setHeader('Connection', 'keep-alive');
|
|
|
|
| 623 |
let fullText = "";
|
| 624 |
|
| 625 |
try {
|
|
|
|
|
|
|
| 626 |
fullText = await streamContentWithSmartFallback({
|
| 627 |
contents: [...geminiHistory, { role: 'user', parts: currentParts }],
|
| 628 |
config: {
|
| 629 |
systemInstruction: systemInstruction
|
| 630 |
}
|
| 631 |
+
}, res, req.headers['x-user-username'], 'chat', enableThinking, enableSearch);
|
| 632 |
|
|
|
|
|
|
|
|
|
|
| 633 |
await ChatHistoryModel.create({
|
| 634 |
userId: req.headers['x-user-username'],
|
| 635 |
role: 'user',
|
|
|
|
| 637 |
timestamp: Date.now()
|
| 638 |
});
|
| 639 |
|
|
|
|
|
|
|
|
|
|
| 640 |
if (fullText && fullText.length > 2 && !req.body.disableAudio) {
|
| 641 |
res.write(`data: ${JSON.stringify({ type: 'status', status: 'tts' })}\n\n`);
|
| 642 |
try {
|
|
|
|
| 644 |
const keys = await getKeyPool('gemini');
|
| 645 |
let audioData = null;
|
| 646 |
|
|
|
|
| 647 |
for (const apiKey of keys) {
|
| 648 |
try {
|
| 649 |
const client = new GoogleGenAI({ apiKey });
|
| 650 |
const ttsResponse = await client.models.generateContent({
|
| 651 |
model: "gemini-2.5-flash-preview-tts",
|
| 652 |
+
contents: [{ parts: [{ text: fullText.substring(0, 500) }] }],
|
| 653 |
config: { responseModalities: ['AUDIO'], speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } } }
|
| 654 |
});
|
| 655 |
audioData = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
|
|
|
|
| 663 |
res.write(`data: ${JSON.stringify({ type: 'status', ttsSkipped: true })}\n\n`);
|
| 664 |
}
|
| 665 |
} catch (ttsErr) {
|
|
|
|
| 666 |
res.write(`data: ${JSON.stringify({ type: 'status', ttsSkipped: true })}\n\n`);
|
| 667 |
}
|
| 668 |
} else {
|
| 669 |
res.write(`data: ${JSON.stringify({ type: 'status', ttsSkipped: true })}\n\n`);
|
| 670 |
}
|
| 671 |
|
|
|
|
|
|
|
|
|
|
| 672 |
await ChatHistoryModel.create({
|
| 673 |
userId: req.headers['x-user-username'],
|
| 674 |
role: 'model',
|
|
|
|
| 688 |
}
|
| 689 |
});
|
| 690 |
|
|
|
|
| 691 |
router.post('/evaluate', checkAIAccess, async (req, res) => {
|
| 692 |
const { question, audio, image, images } = req.body;
|
|
|
|
|
|
|
| 693 |
const username = req.headers['x-user-username'];
|
| 694 |
const user = await User.findOne({ username });
|
| 695 |
|
|
|
|
| 707 |
evalParts.push({ inlineData: { mimeType: 'audio/webm', data: audio } });
|
| 708 |
}
|
| 709 |
|
|
|
|
| 710 |
if (images && Array.isArray(images) && images.length > 0) {
|
| 711 |
evalParts.push({ text: "学生的回答写在以下图片中,请识别所有图片中的文字内容并进行批改:" });
|
| 712 |
images.forEach(img => {
|
| 713 |
if(img) evalParts.push({ inlineData: { mimeType: 'image/jpeg', data: img } });
|
| 714 |
});
|
| 715 |
} else if (image) {
|
|
|
|
| 716 |
evalParts.push({ text: "学生的回答写在图片中,请识别图片中的文字内容并进行批改。" });
|
| 717 |
evalParts.push({ inlineData: { mimeType: 'image/jpeg', data: image } });
|
| 718 |
}
|
| 719 |
|
|
|
|
| 720 |
evalParts.push({ text: `请分析:1. 内容准确性 2. 表达/书写规范。
|
| 721 |
必须严格按照以下格式输出(不要使用Markdown代码块包裹):
|
| 722 |
|
|
|
|
| 729 |
## Score
|
| 730 |
(在此处仅输出一个0-100的数字)` });
|
| 731 |
|
|
|
|
| 732 |
const fullText = await streamContentWithSmartFallback({
|
|
|
|
| 733 |
contents: [{ role: 'user', parts: evalParts }],
|
| 734 |
+
}, res, user?._id, 'evaluate');
|
|
|
|
| 735 |
|
|
|
|
| 736 |
const feedbackMatch = fullText.match(/## Feedback\s+([\s\S]*?)(?=## Score|$)/i);
|
| 737 |
const feedbackText = feedbackMatch ? feedbackMatch[1].trim() : "";
|
| 738 |
|
|
|
|
| 739 |
if (feedbackText) {
|
| 740 |
res.write(`data: ${JSON.stringify({ status: 'tts' })}\n\n`);
|
| 741 |
try {
|
components/ai/ChatPanel.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useRef, useEffect } from 'react';
|
| 3 |
import { AIChatMessage, User } from '../../types';
|
| 4 |
-
import { Bot, Mic, Square, Volume2, Send, Sparkles, Loader2, Image as ImageIcon, Trash2, X, StopCircle } from 'lucide-react';
|
| 5 |
import ReactMarkdown from 'react-markdown';
|
| 6 |
import remarkGfm from 'remark-gfm';
|
| 7 |
import { blobToBase64, base64ToUint8Array, decodePCM, cleanTextForTTS, compressImage } from '../../utils/mediaHelpers';
|
|
@@ -30,6 +30,10 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
|
|
| 30 |
const [textInput, setTextInput] = useState('');
|
| 31 |
const [isRecording, setIsRecording] = useState(false);
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
// Attachments
|
| 34 |
const [selectedImages, setSelectedImages] = useState<File[]>([]);
|
| 35 |
const [audioAttachment, setAudioAttachment] = useState<string | null>(null); // Base64
|
|
@@ -37,6 +41,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
|
|
| 37 |
const [isChatProcessing, setIsChatProcessing] = useState(false);
|
| 38 |
const [playingMessageId, setPlayingMessageId] = useState<string | null>(null);
|
| 39 |
const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
|
|
|
|
| 40 |
|
| 41 |
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
| 42 |
const audioChunksRef = useRef<Blob[]>([]);
|
|
@@ -70,7 +75,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
|
|
| 70 |
|
| 71 |
useEffect(() => {
|
| 72 |
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
| 73 |
-
}, [messages, isChatProcessing]);
|
| 74 |
|
| 75 |
const stopPlayback = () => {
|
| 76 |
if (currentSourceRef.current) {
|
|
@@ -179,14 +184,11 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
|
|
| 179 |
|
| 180 |
setIsChatProcessing(true);
|
| 181 |
|
| 182 |
-
// Define ID outside try block so it is available in catch block
|
| 183 |
const newAiMsgId = (Date.now() + 1).toString();
|
| 184 |
|
| 185 |
try {
|
| 186 |
-
// Process Images to Base64 first to update UI correctly
|
| 187 |
const base64Images = await Promise.all(currentImages.map(f => compressImage(f)));
|
| 188 |
|
| 189 |
-
// Optimistic UI Update with Base64 Images
|
| 190 |
const newUserMsg: AIChatMessage = {
|
| 191 |
id: Date.now().toString(),
|
| 192 |
role: 'user',
|
|
@@ -196,9 +198,10 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
|
|
| 196 |
timestamp: Date.now()
|
| 197 |
};
|
| 198 |
|
| 199 |
-
const newAiMsg: AIChatMessage = { id: newAiMsgId, role: 'model', text: '', timestamp: Date.now() };
|
| 200 |
|
| 201 |
setMessages(prev => [...prev, newUserMsg, newAiMsg]);
|
|
|
|
| 202 |
|
| 203 |
const response = await fetch('/api/ai/chat', {
|
| 204 |
method: 'POST',
|
|
@@ -212,7 +215,9 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
|
|
| 212 |
text: currentText,
|
| 213 |
audio: currentAudio,
|
| 214 |
images: base64Images,
|
| 215 |
-
history: messages.filter(m => m.id !== 'welcome').map(m => ({ role: m.role, text: m.text }))
|
|
|
|
|
|
|
| 216 |
})
|
| 217 |
});
|
| 218 |
|
|
@@ -222,6 +227,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
|
|
| 222 |
const reader = response.body.getReader();
|
| 223 |
const decoder = new TextDecoder();
|
| 224 |
let aiTextAccumulated = '';
|
|
|
|
| 225 |
let buffer = '';
|
| 226 |
|
| 227 |
while (true) {
|
|
@@ -238,37 +244,32 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
|
|
| 238 |
try {
|
| 239 |
const data = JSON.parse(jsonStr);
|
| 240 |
|
| 241 |
-
// 1. Text Update
|
| 242 |
if (data.type === 'text') {
|
|
|
|
|
|
|
|
|
|
| 243 |
aiTextAccumulated += data.content;
|
| 244 |
setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: aiTextAccumulated } : m));
|
| 245 |
}
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
| 248 |
else if (data.type === 'status' && data.status === 'tts') {
|
| 249 |
setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, isGeneratingAudio: true } : m));
|
| 250 |
}
|
| 251 |
-
|
| 252 |
-
// 3. Audio Arrived
|
| 253 |
else if (data.type === 'audio') {
|
| 254 |
-
// Update state: save audio, clear generating flag
|
| 255 |
setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, audio: data.audio, isGeneratingAudio: false } : m));
|
| 256 |
-
// Auto-play
|
| 257 |
const tempMsg = { ...newAiMsg, text: aiTextAccumulated, audio: data.audio };
|
| 258 |
playAudio(tempMsg);
|
| 259 |
}
|
| 260 |
-
|
| 261 |
-
// 4. TTS Skipped (play browser TTS)
|
| 262 |
else if (data.type === 'status' && data.ttsSkipped) {
|
| 263 |
setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, isGeneratingAudio: false } : m));
|
| 264 |
-
// Fallback play
|
| 265 |
if (aiTextAccumulated) {
|
| 266 |
const tempMsg = { ...newAiMsg, text: aiTextAccumulated };
|
| 267 |
playAudio(tempMsg);
|
| 268 |
}
|
| 269 |
}
|
| 270 |
-
|
| 271 |
-
// 5. Error
|
| 272 |
else if (data.type === 'error') {
|
| 273 |
setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: `⚠️ 错误: ${data.message}`, isGeneratingAudio: false } : m));
|
| 274 |
}
|
|
@@ -291,12 +292,13 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
|
|
| 291 |
<div className="flex-1 flex flex-col max-w-4xl mx-auto w-full min-h-0 relative overflow-hidden h-full">
|
| 292 |
{toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
|
| 293 |
|
| 294 |
-
<div className="absolute top-2 right-4 z-10">
|
| 295 |
<button onClick={clearHistory} className="text-xs text-gray-400 hover:text-red-500 flex items-center gap-1 bg-white/80 p-1.5 rounded-lg border border-transparent hover:border-red-100 transition-all shadow-sm backdrop-blur">
|
| 296 |
<Trash2 size={14}/> 清除
|
| 297 |
</button>
|
| 298 |
</div>
|
| 299 |
|
|
|
|
| 300 |
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-4 custom-scrollbar">
|
| 301 |
{messages.map(msg => (
|
| 302 |
<div key={msg.id} className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
|
|
@@ -304,6 +306,24 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
|
|
| 304 |
{msg.role === 'model' ? <Sparkles size={20}/> : <Bot size={20}/>}
|
| 305 |
</div>
|
| 306 |
<div className={`max-w-[85%] flex flex-col items-start ${msg.role === 'user' ? 'items-end' : ''}`}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
<div className={`p-3 rounded-2xl text-sm overflow-hidden shadow-sm ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white border border-gray-200 text-gray-800 rounded-tl-none'}`}>
|
| 308 |
{msg.images && msg.images.length > 0 && (
|
| 309 |
<div className="flex gap-2 mb-2 flex-wrap">
|
|
@@ -327,7 +347,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
|
|
| 327 |
</div>
|
| 328 |
)}
|
| 329 |
|
| 330 |
-
{/* Play Button Logic */}
|
| 331 |
{(msg.role === 'model' && (msg.text || msg.audio) && !isChatProcessing && !msg.isGeneratingAudio) && (
|
| 332 |
<button
|
| 333 |
onClick={() => playingMessageId === msg.id ? stopPlayback() : playAudio(msg)}
|
|
@@ -351,6 +370,27 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
|
|
| 351 |
{/* Improved Input Area */}
|
| 352 |
<div className="p-4 bg-white border-t border-gray-200 shrink-0 z-20">
|
| 353 |
<div className="max-w-4xl mx-auto flex flex-col gap-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
{/* Attachments Preview */}
|
| 355 |
{(selectedImages.length > 0 || audioAttachment) && (
|
| 356 |
<div className="flex gap-2 overflow-x-auto pb-2">
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useRef, useEffect } from 'react';
|
| 3 |
import { AIChatMessage, User } from '../../types';
|
| 4 |
+
import { Bot, Mic, Square, Volume2, Send, Sparkles, Loader2, Image as ImageIcon, Trash2, X, StopCircle, Globe, Brain } from 'lucide-react';
|
| 5 |
import ReactMarkdown from 'react-markdown';
|
| 6 |
import remarkGfm from 'remark-gfm';
|
| 7 |
import { blobToBase64, base64ToUint8Array, decodePCM, cleanTextForTTS, compressImage } from '../../utils/mediaHelpers';
|
|
|
|
| 30 |
const [textInput, setTextInput] = useState('');
|
| 31 |
const [isRecording, setIsRecording] = useState(false);
|
| 32 |
|
| 33 |
+
// Config States
|
| 34 |
+
const [enableThinking, setEnableThinking] = useState(false);
|
| 35 |
+
const [enableSearch, setEnableSearch] = useState(false);
|
| 36 |
+
|
| 37 |
// Attachments
|
| 38 |
const [selectedImages, setSelectedImages] = useState<File[]>([]);
|
| 39 |
const [audioAttachment, setAudioAttachment] = useState<string | null>(null); // Base64
|
|
|
|
| 41 |
const [isChatProcessing, setIsChatProcessing] = useState(false);
|
| 42 |
const [playingMessageId, setPlayingMessageId] = useState<string | null>(null);
|
| 43 |
const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
|
| 44 |
+
const [isThinkingExpanded, setIsThinkingExpanded] = useState<Record<string, boolean>>({});
|
| 45 |
|
| 46 |
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
| 47 |
const audioChunksRef = useRef<Blob[]>([]);
|
|
|
|
| 75 |
|
| 76 |
useEffect(() => {
|
| 77 |
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
| 78 |
+
}, [messages, isChatProcessing, isThinkingExpanded]);
|
| 79 |
|
| 80 |
const stopPlayback = () => {
|
| 81 |
if (currentSourceRef.current) {
|
|
|
|
| 184 |
|
| 185 |
setIsChatProcessing(true);
|
| 186 |
|
|
|
|
| 187 |
const newAiMsgId = (Date.now() + 1).toString();
|
| 188 |
|
| 189 |
try {
|
|
|
|
| 190 |
const base64Images = await Promise.all(currentImages.map(f => compressImage(f)));
|
| 191 |
|
|
|
|
| 192 |
const newUserMsg: AIChatMessage = {
|
| 193 |
id: Date.now().toString(),
|
| 194 |
role: 'user',
|
|
|
|
| 198 |
timestamp: Date.now()
|
| 199 |
};
|
| 200 |
|
| 201 |
+
const newAiMsg: AIChatMessage = { id: newAiMsgId, role: 'model', text: '', thought: '', timestamp: Date.now() };
|
| 202 |
|
| 203 |
setMessages(prev => [...prev, newUserMsg, newAiMsg]);
|
| 204 |
+
if (enableThinking) setIsThinkingExpanded(prev => ({ ...prev, [newAiMsgId]: true }));
|
| 205 |
|
| 206 |
const response = await fetch('/api/ai/chat', {
|
| 207 |
method: 'POST',
|
|
|
|
| 215 |
text: currentText,
|
| 216 |
audio: currentAudio,
|
| 217 |
images: base64Images,
|
| 218 |
+
history: messages.filter(m => m.id !== 'welcome').map(m => ({ role: m.role, text: m.text })),
|
| 219 |
+
enableThinking,
|
| 220 |
+
enableSearch
|
| 221 |
})
|
| 222 |
});
|
| 223 |
|
|
|
|
| 227 |
const reader = response.body.getReader();
|
| 228 |
const decoder = new TextDecoder();
|
| 229 |
let aiTextAccumulated = '';
|
| 230 |
+
let aiThoughtAccumulated = '';
|
| 231 |
let buffer = '';
|
| 232 |
|
| 233 |
while (true) {
|
|
|
|
| 244 |
try {
|
| 245 |
const data = JSON.parse(jsonStr);
|
| 246 |
|
|
|
|
| 247 |
if (data.type === 'text') {
|
| 248 |
+
if (aiTextAccumulated === '' && aiThoughtAccumulated !== '') {
|
| 249 |
+
setIsThinkingExpanded(prev => ({ ...prev, [newAiMsgId]: false }));
|
| 250 |
+
}
|
| 251 |
aiTextAccumulated += data.content;
|
| 252 |
setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: aiTextAccumulated } : m));
|
| 253 |
}
|
| 254 |
+
else if (data.type === 'thinking') {
|
| 255 |
+
aiThoughtAccumulated += data.content;
|
| 256 |
+
setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, thought: aiThoughtAccumulated } : m));
|
| 257 |
+
}
|
| 258 |
else if (data.type === 'status' && data.status === 'tts') {
|
| 259 |
setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, isGeneratingAudio: true } : m));
|
| 260 |
}
|
|
|
|
|
|
|
| 261 |
else if (data.type === 'audio') {
|
|
|
|
| 262 |
setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, audio: data.audio, isGeneratingAudio: false } : m));
|
|
|
|
| 263 |
const tempMsg = { ...newAiMsg, text: aiTextAccumulated, audio: data.audio };
|
| 264 |
playAudio(tempMsg);
|
| 265 |
}
|
|
|
|
|
|
|
| 266 |
else if (data.type === 'status' && data.ttsSkipped) {
|
| 267 |
setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, isGeneratingAudio: false } : m));
|
|
|
|
| 268 |
if (aiTextAccumulated) {
|
| 269 |
const tempMsg = { ...newAiMsg, text: aiTextAccumulated };
|
| 270 |
playAudio(tempMsg);
|
| 271 |
}
|
| 272 |
}
|
|
|
|
|
|
|
| 273 |
else if (data.type === 'error') {
|
| 274 |
setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: `⚠️ 错误: ${data.message}`, isGeneratingAudio: false } : m));
|
| 275 |
}
|
|
|
|
| 292 |
<div className="flex-1 flex flex-col max-w-4xl mx-auto w-full min-h-0 relative overflow-hidden h-full">
|
| 293 |
{toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
|
| 294 |
|
| 295 |
+
<div className="absolute top-2 right-4 z-10 flex gap-2">
|
| 296 |
<button onClick={clearHistory} className="text-xs text-gray-400 hover:text-red-500 flex items-center gap-1 bg-white/80 p-1.5 rounded-lg border border-transparent hover:border-red-100 transition-all shadow-sm backdrop-blur">
|
| 297 |
<Trash2 size={14}/> 清除
|
| 298 |
</button>
|
| 299 |
</div>
|
| 300 |
|
| 301 |
+
{/* Chat History */}
|
| 302 |
<div className="flex-1 overflow-y-auto p-4 space-y-4 pb-4 custom-scrollbar">
|
| 303 |
{messages.map(msg => (
|
| 304 |
<div key={msg.id} className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
|
|
|
|
| 306 |
{msg.role === 'model' ? <Sparkles size={20}/> : <Bot size={20}/>}
|
| 307 |
</div>
|
| 308 |
<div className={`max-w-[85%] flex flex-col items-start ${msg.role === 'user' ? 'items-end' : ''}`}>
|
| 309 |
+
{msg.role === 'model' && msg.thought && (
|
| 310 |
+
<div className="w-full bg-purple-50 rounded-xl border border-purple-100 overflow-hidden mb-2 max-w-full">
|
| 311 |
+
<button
|
| 312 |
+
onClick={() => setIsThinkingExpanded(prev => ({ ...prev, [msg.id]: !prev[msg.id] }))}
|
| 313 |
+
className="w-full px-4 py-2 flex items-center gap-2 text-xs font-bold text-purple-700 bg-purple-100/50 hover:bg-purple-100 transition-colors"
|
| 314 |
+
>
|
| 315 |
+
<Brain size={14}/>
|
| 316 |
+
<span>深度思考过程</span>
|
| 317 |
+
<div className="ml-auto text-xs opacity-50">{isThinkingExpanded[msg.id] ? '收起' : '展开'}</div>
|
| 318 |
+
</button>
|
| 319 |
+
{isThinkingExpanded[msg.id] && (
|
| 320 |
+
<div className="p-4 text-xs text-purple-800 whitespace-pre-wrap leading-relaxed border-t border-purple-100 font-mono bg-white/50">
|
| 321 |
+
{msg.thought}
|
| 322 |
+
</div>
|
| 323 |
+
)}
|
| 324 |
+
</div>
|
| 325 |
+
)}
|
| 326 |
+
|
| 327 |
<div className={`p-3 rounded-2xl text-sm overflow-hidden shadow-sm ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white border border-gray-200 text-gray-800 rounded-tl-none'}`}>
|
| 328 |
{msg.images && msg.images.length > 0 && (
|
| 329 |
<div className="flex gap-2 mb-2 flex-wrap">
|
|
|
|
| 347 |
</div>
|
| 348 |
)}
|
| 349 |
|
|
|
|
| 350 |
{(msg.role === 'model' && (msg.text || msg.audio) && !isChatProcessing && !msg.isGeneratingAudio) && (
|
| 351 |
<button
|
| 352 |
onClick={() => playingMessageId === msg.id ? stopPlayback() : playAudio(msg)}
|
|
|
|
| 370 |
{/* Improved Input Area */}
|
| 371 |
<div className="p-4 bg-white border-t border-gray-200 shrink-0 z-20">
|
| 372 |
<div className="max-w-4xl mx-auto flex flex-col gap-2">
|
| 373 |
+
|
| 374 |
+
{/* Toolbar */}
|
| 375 |
+
<div className="flex justify-between items-center px-1">
|
| 376 |
+
<div className="flex gap-3">
|
| 377 |
+
<button
|
| 378 |
+
onClick={() => setEnableThinking(!enableThinking)}
|
| 379 |
+
className={`flex items-center gap-1 text-xs px-2 py-1 rounded-md border transition-all ${enableThinking ? 'bg-purple-50 text-purple-600 border-purple-200' : 'bg-gray-50 text-gray-500 border-transparent hover:bg-gray-100'}`}
|
| 380 |
+
title="开启后AI将进行深度思考 (仅部分模型支持)"
|
| 381 |
+
>
|
| 382 |
+
<Brain size={14} className={enableThinking ? "fill-current" : ""}/> 深度思考
|
| 383 |
+
</button>
|
| 384 |
+
<button
|
| 385 |
+
onClick={() => setEnableSearch(!enableSearch)}
|
| 386 |
+
className={`flex items-center gap-1 text-xs px-2 py-1 rounded-md border transition-all ${enableSearch ? 'bg-blue-50 text-blue-600 border-blue-200' : 'bg-gray-50 text-gray-500 border-transparent hover:bg-gray-100'}`}
|
| 387 |
+
title="开启后AI将联网搜索最新信息 (Doubao模型支持)"
|
| 388 |
+
>
|
| 389 |
+
<Globe size={14}/> 联网搜索
|
| 390 |
+
</button>
|
| 391 |
+
</div>
|
| 392 |
+
</div>
|
| 393 |
+
|
| 394 |
{/* Attachments Preview */}
|
| 395 |
{(selectedImages.length > 0 || audioAttachment) && (
|
| 396 |
<div className="flex gap-2 overflow-x-auto pb-2">
|
components/ai/WorkAssistantPanel.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
|
| 2 |
import React, { useState, useRef, useEffect } from 'react';
|
| 3 |
import { AIChatMessage, User } from '../../types';
|
| 4 |
-
import { Bot, Send, Sparkles, Loader2, Image as ImageIcon, X, Trash2, Brain, ChevronDown, ChevronRight, Copy, Check, FileText, Plus, Paperclip, File } from 'lucide-react';
|
| 5 |
import ReactMarkdown from 'react-markdown';
|
| 6 |
import remarkGfm from 'remark-gfm';
|
| 7 |
import { compressImage } from '../../utils/mediaHelpers';
|
|
@@ -142,6 +142,7 @@ const ROLES = [
|
|
| 142 |
export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentUser }) => {
|
| 143 |
const [selectedRole, setSelectedRole] = useState(ROLES[0]);
|
| 144 |
const [enableThinking, setEnableThinking] = useState(false);
|
|
|
|
| 145 |
|
| 146 |
// Chat State
|
| 147 |
const [messages, setMessages] = useState<AIChatMessage[]>([]);
|
|
@@ -217,7 +218,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 217 |
setSelectedImages([]);
|
| 218 |
clearDoc();
|
| 219 |
|
| 220 |
-
// ID generation using UUID to prevent collisions
|
| 221 |
const userMsgId = crypto.randomUUID();
|
| 222 |
const aiMsgId = crypto.randomUUID();
|
| 223 |
|
|
@@ -241,7 +241,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 241 |
text: '',
|
| 242 |
thought: '',
|
| 243 |
timestamp: Date.now(),
|
| 244 |
-
// Pass user images to AI message so it can render the placeholders using the source images
|
| 245 |
images: base64Images
|
| 246 |
};
|
| 247 |
|
|
@@ -282,6 +281,7 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 282 |
images: base64Images,
|
| 283 |
history: [], // Work assistant keeps context short
|
| 284 |
enableThinking,
|
|
|
|
| 285 |
overrideSystemPrompt: dynamicSystemPrompt,
|
| 286 |
disableAudio: true
|
| 287 |
})
|
|
@@ -312,7 +312,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 312 |
try {
|
| 313 |
const data = JSON.parse(jsonStr);
|
| 314 |
|
| 315 |
-
// Using functional update with ID check to ensure we only update the AI message
|
| 316 |
if (data.type === 'thinking') {
|
| 317 |
aiThoughtAccumulated += data.content;
|
| 318 |
setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, thought: aiThoughtAccumulated } : m));
|
|
@@ -436,16 +435,22 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 436 |
</div>
|
| 437 |
</div>
|
| 438 |
|
| 439 |
-
<div className="flex items-center gap-
|
| 440 |
-
<div className="flex items-center gap-
|
| 441 |
-
<span className="text-sm text-gray-600 font-medium flex items-center gap-1">
|
| 442 |
-
<Brain size={16} className={enableThinking ? "text-purple-600" : "text-gray-400"}/> 深度思考
|
| 443 |
-
</span>
|
| 444 |
<label className="relative inline-flex items-center cursor-pointer">
|
| 445 |
<input type="checkbox" checked={enableThinking} onChange={e => setEnableThinking(e.target.checked)} className="sr-only peer"/>
|
| 446 |
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-purple-600"></div>
|
| 447 |
</label>
|
|
|
|
| 448 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
<button onClick={() => setMessages([])} className="text-gray-400 hover:text-red-500 p-2 rounded-full hover:bg-red-50 transition-colors" title="清空对话">
|
| 450 |
<Trash2 size={18}/>
|
| 451 |
</button>
|
|
@@ -466,12 +471,9 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 466 |
)}
|
| 467 |
|
| 468 |
{messages.map((msg, index) => {
|
| 469 |
-
// Logic to retrieve images from the AI message itself (passed during creation)
|
| 470 |
-
// OR from the preceding user message if it's a model response
|
| 471 |
let sourceImages: string[] = msg.images || [];
|
| 472 |
|
| 473 |
if (msg.role === 'model' && (!sourceImages || sourceImages.length === 0)) {
|
| 474 |
-
// Fallback: look at previous user message
|
| 475 |
const prevMsg = messages[index - 1];
|
| 476 |
if (prevMsg && prevMsg.role === 'user' && prevMsg.images) {
|
| 477 |
sourceImages = prevMsg.images;
|
|
@@ -503,7 +505,7 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 503 |
)}
|
| 504 |
|
| 505 |
<div className={`p-5 rounded-2xl shadow-sm text-sm overflow-hidden relative group w-full ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white text-gray-800 border border-gray-100 rounded-tl-none'}`}>
|
| 506 |
-
{/* User Image Preview Grid
|
| 507 |
{msg.role === 'user' && sourceImages.length > 0 && (
|
| 508 |
<div className="grid grid-cols-4 gap-2 mb-3">
|
| 509 |
{sourceImages.map((img, i) => (
|
|
@@ -577,8 +579,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
|
|
| 577 |
<button onClick={clearDoc} className="absolute top-0.5 right-0.5 text-indigo-300 hover:text-red-500 p-0.5"><X size={12}/></button>
|
| 578 |
</div>
|
| 579 |
)}
|
| 580 |
-
|
| 581 |
-
{/* Add More Button (if needed logic) */}
|
| 582 |
</div>
|
| 583 |
)}
|
| 584 |
|
|
|
|
| 1 |
|
| 2 |
import React, { useState, useRef, useEffect } from 'react';
|
| 3 |
import { AIChatMessage, User } from '../../types';
|
| 4 |
+
import { Bot, Send, Sparkles, Loader2, Image as ImageIcon, X, Trash2, Brain, ChevronDown, ChevronRight, Copy, Check, FileText, Plus, Paperclip, File, Globe } from 'lucide-react';
|
| 5 |
import ReactMarkdown from 'react-markdown';
|
| 6 |
import remarkGfm from 'remark-gfm';
|
| 7 |
import { compressImage } from '../../utils/mediaHelpers';
|
|
|
|
| 142 |
export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentUser }) => {
|
| 143 |
const [selectedRole, setSelectedRole] = useState(ROLES[0]);
|
| 144 |
const [enableThinking, setEnableThinking] = useState(false);
|
| 145 |
+
const [enableSearch, setEnableSearch] = useState(false);
|
| 146 |
|
| 147 |
// Chat State
|
| 148 |
const [messages, setMessages] = useState<AIChatMessage[]>([]);
|
|
|
|
| 218 |
setSelectedImages([]);
|
| 219 |
clearDoc();
|
| 220 |
|
|
|
|
| 221 |
const userMsgId = crypto.randomUUID();
|
| 222 |
const aiMsgId = crypto.randomUUID();
|
| 223 |
|
|
|
|
| 241 |
text: '',
|
| 242 |
thought: '',
|
| 243 |
timestamp: Date.now(),
|
|
|
|
| 244 |
images: base64Images
|
| 245 |
};
|
| 246 |
|
|
|
|
| 281 |
images: base64Images,
|
| 282 |
history: [], // Work assistant keeps context short
|
| 283 |
enableThinking,
|
| 284 |
+
enableSearch,
|
| 285 |
overrideSystemPrompt: dynamicSystemPrompt,
|
| 286 |
disableAudio: true
|
| 287 |
})
|
|
|
|
| 312 |
try {
|
| 313 |
const data = JSON.parse(jsonStr);
|
| 314 |
|
|
|
|
| 315 |
if (data.type === 'thinking') {
|
| 316 |
aiThoughtAccumulated += data.content;
|
| 317 |
setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, thought: aiThoughtAccumulated } : m));
|
|
|
|
| 435 |
</div>
|
| 436 |
</div>
|
| 437 |
|
| 438 |
+
<div className="flex items-center gap-3">
|
| 439 |
+
<div className="flex items-center gap-1" title="开启后AI会进行更深入的逻辑推演">
|
|
|
|
|
|
|
|
|
|
| 440 |
<label className="relative inline-flex items-center cursor-pointer">
|
| 441 |
<input type="checkbox" checked={enableThinking} onChange={e => setEnableThinking(e.target.checked)} className="sr-only peer"/>
|
| 442 |
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-purple-600"></div>
|
| 443 |
</label>
|
| 444 |
+
<span className="text-xs text-gray-600 font-bold">深度思考</span>
|
| 445 |
</div>
|
| 446 |
+
<div className="flex items-center gap-1" title="开启联网搜索">
|
| 447 |
+
<label className="relative inline-flex items-center cursor-pointer">
|
| 448 |
+
<input type="checkbox" checked={enableSearch} onChange={e => setEnableSearch(e.target.checked)} className="sr-only peer"/>
|
| 449 |
+
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
|
| 450 |
+
</label>
|
| 451 |
+
<span className="text-xs text-gray-600 font-bold">联网搜索</span>
|
| 452 |
+
</div>
|
| 453 |
+
<div className="w-px h-6 bg-gray-200 mx-1"></div>
|
| 454 |
<button onClick={() => setMessages([])} className="text-gray-400 hover:text-red-500 p-2 rounded-full hover:bg-red-50 transition-colors" title="清空对话">
|
| 455 |
<Trash2 size={18}/>
|
| 456 |
</button>
|
|
|
|
| 471 |
)}
|
| 472 |
|
| 473 |
{messages.map((msg, index) => {
|
|
|
|
|
|
|
| 474 |
let sourceImages: string[] = msg.images || [];
|
| 475 |
|
| 476 |
if (msg.role === 'model' && (!sourceImages || sourceImages.length === 0)) {
|
|
|
|
| 477 |
const prevMsg = messages[index - 1];
|
| 478 |
if (prevMsg && prevMsg.role === 'user' && prevMsg.images) {
|
| 479 |
sourceImages = prevMsg.images;
|
|
|
|
| 505 |
)}
|
| 506 |
|
| 507 |
<div className={`p-5 rounded-2xl shadow-sm text-sm overflow-hidden relative group w-full ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white text-gray-800 border border-gray-100 rounded-tl-none'}`}>
|
| 508 |
+
{/* User Image Preview Grid */}
|
| 509 |
{msg.role === 'user' && sourceImages.length > 0 && (
|
| 510 |
<div className="grid grid-cols-4 gap-2 mb-3">
|
| 511 |
{sourceImages.map((img, i) => (
|
|
|
|
| 579 |
<button onClick={clearDoc} className="absolute top-0.5 right-0.5 text-indigo-300 hover:text-red-500 p-0.5"><X size={12}/></button>
|
| 580 |
</div>
|
| 581 |
)}
|
|
|
|
|
|
|
| 582 |
</div>
|
| 583 |
)}
|
| 584 |
|
models.js
CHANGED
|
@@ -26,18 +26,21 @@ const UserSchema = new mongoose.Schema({
|
|
| 26 |
seatNo: String,
|
| 27 |
idCard: String,
|
| 28 |
aiAccess: { type: Boolean, default: false },
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
classApplication: {
|
| 32 |
type: { type: String },
|
| 33 |
targetClass: String,
|
| 34 |
status: String
|
| 35 |
},
|
| 36 |
-
// NEW: For Principal creating a new school
|
| 37 |
pendingSchoolData: {
|
| 38 |
name: String,
|
| 39 |
code: String,
|
| 40 |
-
type: { type: String }
|
| 41 |
}
|
| 42 |
});
|
| 43 |
const User = mongoose.model('User', UserSchema);
|
|
|
|
| 26 |
seatNo: String,
|
| 27 |
idCard: String,
|
| 28 |
aiAccess: { type: Boolean, default: false },
|
| 29 |
+
// UPDATED: Stores Doubao Context State
|
| 30 |
+
doubaoState: {
|
| 31 |
+
responseId: String, // previous_response_id
|
| 32 |
+
thinkingState: Boolean // Last thinking state (enabled/disabled)
|
| 33 |
+
},
|
| 34 |
+
menuOrder: [String],
|
| 35 |
classApplication: {
|
| 36 |
type: { type: String },
|
| 37 |
targetClass: String,
|
| 38 |
status: String
|
| 39 |
},
|
|
|
|
| 40 |
pendingSchoolData: {
|
| 41 |
name: String,
|
| 42 |
code: String,
|
| 43 |
+
type: { type: String }
|
| 44 |
}
|
| 45 |
});
|
| 46 |
const User = mongoose.model('User', UserSchema);
|