dvc890 commited on
Commit
8e5411a
·
verified ·
1 Parent(s): cd87b40

Upload 67 files

Browse files
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 (still needed for context creation)
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 (Axios) ---
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; // MUST be ep-xxxx
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/chat/completions',
188
- payload,
189
  {
190
  headers: {
191
  'Content-Type': 'application/json',
192
  'Authorization': `Bearer ${apiKey}`
193
  },
194
  responseType: 'stream',
195
- timeout: 60000 // 60s timeout
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 fullThought = "";
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(); // Keep incomplete line
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
- // Handle Thinking Content (DeepSeek/Doubao format usually puts it in reasoning_content)
228
- const reasoning = delta?.reasoning_content;
229
- const content = delta?.content;
 
230
 
231
- if (reasoning) {
232
- fullThought += reasoning;
233
- res.write(`data: ${JSON.stringify({ type: 'thinking', content: reasoning })}\n\n`);
234
  if (res.flush) res.flush();
235
  }
236
 
237
- if (content) {
 
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 or keepalives
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} (URL: ${baseURL})`);
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/Custom stream failed (All models exhausted)");
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, userId, mode = 'chat', enableThinking = false) {
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, userId, mode, config, enableThinking);
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
- // --- PERSISTENT CHAT HISTORY HANDLER ---
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; // Added images, enableThinking, overrideSystemPrompt
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
- // ... (Keep Context building logic same) ...
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) }] }], // Limit TTS length for speed
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
- // NO JSON MODE to allow progressive text streaming
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
- // 2. Status Update (e.g. Generating TTS)
 
 
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-4">
440
- <div className="flex items-center gap-2" title="开启后AI会进行更深入的逻辑推演">
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 (Only for user messages) */}
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
- doubaoContextId: { type: String }, // NEW: Stores the Doubao Context ID for this user
30
- menuOrder: [String], // NEW
 
 
 
 
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 } // FIXED: Wrapped in object to avoid Mongoose interpreting parent as String type
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);