dvc890 commited on
Commit
6386c92
·
verified ·
1 Parent(s): df4c6dd

Upload 64 files

Browse files
Files changed (4) hide show
  1. ai-routes.js +126 -175
  2. ai-tools.js +75 -21
  3. components/ai/ChatPanel.tsx +64 -60
  4. types.ts +1 -0
ai-routes.js CHANGED
@@ -68,7 +68,6 @@ router.post('/reset-pool', checkAIAccess, (req, res) => {
68
  res.json({ success: true });
69
  });
70
 
71
- // Helper: Convert Gemini History to OpenAI Messages
72
  function convertHistoryToOpenAI(history) {
73
  return history.map(msg => ({
74
  role: msg.role === 'model' ? 'assistant' : 'user',
@@ -76,13 +75,20 @@ function convertHistoryToOpenAI(history) {
76
  }));
77
  }
78
 
79
- // --- MAIN CHAT ROUTE (Supports Gemini & OpenAI/Doubao Agents) ---
 
 
 
 
 
 
80
  router.post('/chat', checkAIAccess, async (req, res) => {
81
  const { text, audio } = req.body;
82
  const username = req.headers['x-user-username'];
83
  const userRole = req.headers['x-user-role'];
84
  const schoolId = req.headers['x-school-id'];
85
 
 
86
  res.setHeader('Content-Type', 'text/event-stream');
87
  res.setHeader('Cache-Control', 'no-cache');
88
  res.setHeader('Connection', 'keep-alive');
@@ -98,204 +104,149 @@ router.post('/chat', checkAIAccess, async (req, res) => {
98
  await ChatHistoryModel.create({ userId: user._id, role: 'user', text: userMsgText });
99
  }
100
 
101
- // 2. Fetch Config & Context
102
  const config = await ConfigModel.findOne({ key: 'main' });
103
  const contextPrompt = await buildUserContext(username, userRole, schoolId);
104
 
105
- // Determine Provider: Default Gemini, check order
106
- const providerOrder = config?.aiProviderOrder && config.aiProviderOrder.length > 0
107
- ? config.aiProviderOrder
108
- : ['GEMINI', 'OPENROUTER'];
109
 
110
- // For simplicity, we grab the first working one.
111
- const activeProvider = providerOrder[0];
 
 
 
 
 
112
 
113
- // --- GEMINI AGENT PATH ---
114
- if (activeProvider === 'GEMINI') {
115
- console.log(`🤖 [Agent] Using Provider: Google Gemini`);
116
- const { GoogleGenAI } = await import("@google/genai");
117
- const keys = await getKeyPool('gemini');
118
- if (keys.length === 0) throw new Error("No Gemini API keys");
119
-
120
- const dbHistory = await ChatHistoryModel.find({ userId: user._id }).sort({ timestamp: -1 }).limit(10);
121
- const historyContents = dbHistory.reverse().map(msg => ({
122
- role: msg.role === 'user' ? 'user' : 'model',
123
- parts: [{ text: msg.text }]
124
- }));
125
- const currentParts = [];
126
- if (text) currentParts.push({ text });
127
- if (audio) currentParts.push({ inlineData: { mimeType: 'audio/webm', data: audio } });
 
 
 
 
 
 
 
 
128
 
129
- let conversation = [...historyContents];
130
- if (currentParts.length > 0) conversation.push({ role: 'user', parts: currentParts });
131
-
132
- const client = new GoogleGenAI({ apiKey: keys[0] });
133
- const modelName = 'gemini-2.5-flash';
 
 
 
134
 
135
- // Agent Loop (Max 3 turns)
136
- let turnCount = 0;
137
- let finalResponseText = "";
 
 
 
 
 
 
 
 
 
 
138
 
139
- while (turnCount < 3) {
140
- const result = await client.models.generateContent({
141
- model: modelName,
142
- contents: conversation,
143
- config: {
144
- systemInstruction: `${contextPrompt}\n\n重要:如果用户查询具体数据,请使用 query_database 工具。`,
145
- tools: mongoTools
 
 
 
 
 
 
 
146
  }
147
- });
148
-
149
- const candidate = result.candidates[0];
150
- const content = candidate.content;
151
- conversation.push(content);
152
-
153
- const functionCalls = content.parts.filter(p => p.functionCall).map(p => p.functionCall);
154
-
155
- if (functionCalls.length > 0) {
156
- console.log(`⚡ [Gemini Agent] Decided to call tool (${functionCalls.length} calls)`);
157
- const functionResponses = await Promise.all(functionCalls.map(async (call) => {
158
- const toolResult = await executeMongoTool(call, user, userRole, schoolId);
159
- return { id: call.id, name: call.name, response: { result: toolResult } };
160
- }));
161
- conversation.push({ parts: functionResponses.map(resp => ({ functionResponse: resp })) });
162
- turnCount++;
163
- } else {
164
- finalResponseText = content.parts.map(p => p.text).join('');
165
- break;
166
  }
167
  }
168
- await streamResponse(finalResponseText, user, res, client);
169
- }
170
-
171
- // --- OPENAI / DOUBAO AGENT PATH ---
172
- else {
173
- console.log(`🤖 [Agent] Using Provider: OpenAI / Doubao`);
174
- const keys = await getKeyPool('openrouter'); // Also serves as Doubao key pool if configured
175
- if (keys.length === 0) throw new Error("No OpenAI/Doubao API keys");
176
-
177
- // Determine Model (Doubao or default)
178
- let modelName = 'qwen/qwen3-coder:free';
179
- let apiUrl = 'https://openrouter.ai/api/v1'; // Default
180
-
181
- if (config?.openRouterModels && config.openRouterModels.length > 0) {
182
- const m = config.openRouterModels[0];
183
- modelName = m.id;
184
- if (m.apiUrl) apiUrl = m.apiUrl; // Support Custom URL (e.g. Doubao Endpoint)
185
- }
186
 
187
- console.log(` Model: ${modelName} @ ${apiUrl}`);
188
-
189
- const client = new OpenAI({ baseURL: apiUrl, apiKey: keys[0], defaultHeaders: { "HTTP-Referer": "https://smart.com" } });
190
-
191
- // Build Messages
192
- const dbHistory = await ChatHistoryModel.find({ userId: user._id }).sort({ timestamp: -1 }).limit(10);
193
- const messages = [
194
- { role: 'system', content: `${contextPrompt}\n\n重要:如果用户查询具体数据,请使用 query_database 工具。` },
195
- ...convertHistoryToOpenAI(dbHistory.reverse())
196
- ];
197
- if (text) messages.push({ role: 'user', content: text });
198
-
199
- let turnCount = 0;
200
- let finalResponseText = "";
201
-
202
- while (turnCount < 3) {
203
- const completion = await client.chat.completions.create({
204
- model: modelName,
205
- messages: messages,
206
- tools: getOpenAITools(),
207
- tool_choice: "auto"
208
  });
209
 
210
- const msg = completion.choices[0].message;
211
- messages.push(msg);
212
-
213
- if (msg.tool_calls && msg.tool_calls.length > 0) {
214
- console.log(`⚡ [Doubao/OpenAI] Agent request Local Tool Execution (Simulating MCP)...`);
215
-
216
- for (const toolCall of msg.tool_calls) {
217
- // Execute Tool Locally
218
- const toolResult = await executeMongoTool({
219
- name: toolCall.function.name,
220
- args: undefined,
221
- arguments: toolCall.function.arguments
222
- }, user, userRole, schoolId);
223
-
224
- messages.push({
225
- role: "tool",
226
- tool_call_id: toolCall.id,
227
- content: JSON.stringify(toolResult)
228
- });
229
- }
230
- turnCount++;
231
- } else {
232
- finalResponseText = msg.content;
233
- break;
234
  }
 
 
 
 
 
 
235
  }
236
- await streamResponse(finalResponseText, user, res);
237
  }
238
 
 
 
 
 
 
 
 
 
239
  } catch (e) {
240
  console.error("[AI Chat Error]", e);
241
- res.write(`data: ${JSON.stringify({ error: true, message: e.message })}\n\n`);
242
  res.end();
243
  }
244
  });
245
 
246
- // Helper to stream text and generate TTS
247
- async function streamResponse(text, user, res, geminiClient = null) {
248
- if (!text) {
249
- res.write('data: [DONE]\n\n');
250
- return res.end();
251
- }
252
-
253
- // Save
254
- await ChatHistoryModel.create({ userId: user._id, role: 'model', text: text });
255
- recordUsage('agent-response', 'AGENT');
256
-
257
- // Stream Text
258
- res.write(`data: ${JSON.stringify({ text })}\n\n`);
259
-
260
- // TTS
261
- res.write(`data: ${JSON.stringify({ status: 'tts' })}\n\n`);
262
- try {
263
- let audioBytes = null;
264
- if (geminiClient) {
265
- const ttsResponse = await geminiClient.models.generateContent({
266
- model: "gemini-2.5-flash-preview-tts",
267
- contents: [{ parts: [{ text }] }],
268
- config: { responseModalities: ['AUDIO'], speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } } }
269
- });
270
- audioBytes = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
271
- } else {
272
- const keys = await getKeyPool('gemini');
273
- if (keys.length > 0) {
274
- const { GoogleGenAI } = await import("@google/genai");
275
- const ttsClient = new GoogleGenAI({ apiKey: keys[0] });
276
- const ttsResponse = await ttsClient.models.generateContent({
277
- model: "gemini-2.5-flash-preview-tts",
278
- contents: [{ parts: [{ text }] }],
279
- config: { responseModalities: ['AUDIO'], speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } } }
280
- });
281
- audioBytes = ttsResponse.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
282
- }
283
- }
284
-
285
- if (audioBytes) res.write(`data: ${JSON.stringify({ audio: audioBytes })}\n\n`);
286
- else res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`);
287
- } catch (ttsError) {
288
- console.error("TTS Error", ttsError);
289
- res.write(`data: ${JSON.stringify({ ttsSkipped: true })}\n\n`);
290
- }
291
-
292
- res.write('data: [DONE]\n\n');
293
- res.end();
294
- }
295
-
296
- // ... (Evaluate route unchanged)
297
  router.post('/evaluate', checkAIAccess, async (req, res) => {
298
- // ... same as before ...
299
  const { question, audio, image, images } = req.body;
300
  res.setHeader('Content-Type', 'text/event-stream');
301
  res.setHeader('Cache-Control', 'no-cache');
 
68
  res.json({ success: true });
69
  });
70
 
 
71
  function convertHistoryToOpenAI(history) {
72
  return history.map(msg => ({
73
  role: msg.role === 'model' ? 'assistant' : 'user',
 
75
  }));
76
  }
77
 
78
+ // --- SSE Protocol Helper ---
79
+ // Sends structured events to client: { type: 'text'|'thought'|'done'|'error', content?: string }
80
+ const sendSSE = (res, data) => {
81
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
82
+ };
83
+
84
+ // --- REAL STREAMING CHAT ROUTE ---
85
  router.post('/chat', checkAIAccess, async (req, res) => {
86
  const { text, audio } = req.body;
87
  const username = req.headers['x-user-username'];
88
  const userRole = req.headers['x-user-role'];
89
  const schoolId = req.headers['x-school-id'];
90
 
91
+ // SSE Setup
92
  res.setHeader('Content-Type', 'text/event-stream');
93
  res.setHeader('Cache-Control', 'no-cache');
94
  res.setHeader('Connection', 'keep-alive');
 
104
  await ChatHistoryModel.create({ userId: user._id, role: 'user', text: userMsgText });
105
  }
106
 
 
107
  const config = await ConfigModel.findOne({ key: 'main' });
108
  const contextPrompt = await buildUserContext(username, userRole, schoolId);
109
 
110
+ // Setup OpenAI Client (Used for both Doubao and OpenRouter)
111
+ const keys = await getKeyPool('openrouter');
112
+ if (keys.length === 0) throw new Error("No API keys available");
 
113
 
114
+ let modelName = 'qwen/qwen3-coder:free';
115
+ let apiUrl = 'https://openrouter.ai/api/v1';
116
+ if (config?.openRouterModels && config.openRouterModels.length > 0) {
117
+ const m = config.openRouterModels[0];
118
+ modelName = m.id;
119
+ if (m.apiUrl) apiUrl = m.apiUrl;
120
+ }
121
 
122
+ console.log(`🤖 [Streaming Agent] ${modelName} @ ${apiUrl}`);
123
+
124
+ const client = new OpenAI({
125
+ baseURL: apiUrl,
126
+ apiKey: keys[0],
127
+ defaultHeaders: { "HTTP-Referer": "https://smart.com" }
128
+ });
129
+
130
+ // 2. Build History
131
+ const dbHistory = await ChatHistoryModel.find({ userId: user._id }).sort({ timestamp: -1 }).limit(10);
132
+ let messages = [
133
+ { role: 'system', content: `${contextPrompt}\n\n重要:如果用户查询具体数据,请使用 query_database 工具。` },
134
+ ...convertHistoryToOpenAI(dbHistory.reverse())
135
+ ];
136
+ if (text) messages.push({ role: 'user', content: text });
137
+
138
+ // 3. Recursive Agent Loop
139
+ let finalResponseText = "";
140
+ let turnCount = 0;
141
+ const MAX_TURNS = 5;
142
+
143
+ // Loop handles: LLM -> Tool Call -> Tool Result -> LLM -> Answer
144
+ while (turnCount < MAX_TURNS) {
145
 
146
+ // Start Stream for this turn
147
+ const stream = await client.chat.completions.create({
148
+ model: modelName,
149
+ messages: messages,
150
+ tools: getOpenAITools(),
151
+ tool_choice: "auto",
152
+ stream: true // Enable REAL streaming
153
+ });
154
 
155
+ let toolCallBuffer = []; // To accumulate tool call chunks
156
+ let currentContent = "";
157
+
158
+ for await (const chunk of stream) {
159
+ const delta = chunk.choices[0]?.delta;
160
+
161
+ // A. Handle Text Content
162
+ if (delta?.content) {
163
+ currentContent += delta.content;
164
+ finalResponseText += delta.content;
165
+ // Directly stream text to client
166
+ sendSSE(res, { type: 'text', content: delta.content });
167
+ }
168
 
169
+ // B. Handle Tool Calls (Accumulate args)
170
+ if (delta?.tool_calls) {
171
+ const toolCalls = delta.tool_calls;
172
+ for (const toolCall of toolCalls) {
173
+ const index = toolCall.index;
174
+ if (!toolCallBuffer[index]) {
175
+ toolCallBuffer[index] = {
176
+ id: toolCall.id,
177
+ name: toolCall.function?.name || "",
178
+ arguments: ""
179
+ };
180
+ }
181
+ if (toolCall.function?.name) toolCallBuffer[index].name = toolCall.function.name;
182
+ if (toolCall.function?.arguments) toolCallBuffer[index].arguments += toolCall.function.arguments;
183
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  }
185
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
+ // End of stream for this turn.
188
+ // Check if we have tool calls to execute.
189
+ if (toolCallBuffer.length > 0) {
190
+ // Add the assistant's "intent" message to history
191
+ // Note: We reconstruct the message object as if it wasn't streamed
192
+ messages.push({
193
+ role: 'assistant',
194
+ content: currentContent || null, // Content might be null if only calling tools
195
+ tool_calls: toolCallBuffer.map(tc => ({
196
+ id: tc.id || `call_${Date.now()}`,
197
+ type: 'function',
198
+ function: { name: tc.name, arguments: tc.arguments }
199
+ }))
 
 
 
 
 
 
 
 
200
  });
201
 
202
+ // Notify Frontend: Tool Execution Started
203
+ sendSSE(res, { type: 'thought', content: `🤔 正在调用工具: ${toolCallBuffer.map(t => t.name).join(', ')} ...` });
204
+
205
+ // Execute Tools
206
+ for (const toolCall of toolCallBuffer) {
207
+ const toolResult = await executeMongoTool({
208
+ name: toolCall.name,
209
+ args: undefined,
210
+ arguments: toolCall.arguments
211
+ }, user, userRole, schoolId);
212
+
213
+ // Add result to history
214
+ messages.push({
215
+ role: "tool",
216
+ tool_call_id: toolCall.id || `call_${Date.now()}`,
217
+ content: JSON.stringify(toolResult)
218
+ });
219
+
220
+ // Notify Frontend: Tool Result
221
+ const shortResult = JSON.stringify(toolResult).substring(0, 50) + "...";
222
+ sendSSE(res, { type: 'thought', content: `✅ 工具执行完成: ${shortResult}` });
 
 
 
223
  }
224
+
225
+ // Continue loop to let LLM generate answer based on tool result
226
+ turnCount++;
227
+ } else {
228
+ // No tool calls, we are done.
229
+ break;
230
  }
 
231
  }
232
 
233
+ // 4. Save Final Answer
234
+ await ChatHistoryModel.create({ userId: user._id, role: 'model', text: finalResponseText });
235
+ recordUsage('agent-response', 'AGENT');
236
+
237
+ // 5. Send Done Signal
238
+ sendSSE(res, { type: 'done' });
239
+ res.end();
240
+
241
  } catch (e) {
242
  console.error("[AI Chat Error]", e);
243
+ sendSSE(res, { type: 'error', message: e.message });
244
  res.end();
245
  }
246
  });
247
 
248
+ // ... (Rest of the file: evaluate route, export)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  router.post('/evaluate', checkAIAccess, async (req, res) => {
 
250
  const { question, audio, image, images } = req.body;
251
  res.setHeader('Content-Type', 'text/event-stream');
252
  res.setHeader('Cache-Control', 'no-cache');
ai-tools.js CHANGED
@@ -58,7 +58,6 @@ function injectSecurityFilter(filter, user, role, schoolId) {
58
  return safeFilter;
59
  }
60
 
61
- // 老师只能看自己相关的班级逻辑可以在这里加强
62
  if (role === 'TEACHER') {
63
  // 暂时不做强制过滤,依赖业务层逻辑
64
  }
@@ -74,7 +73,6 @@ function injectSecurityFilter(filter, user, role, schoolId) {
74
 
75
  /**
76
  * 辅助:递归修正查询字段名 (Deep Normalization)
77
- * AI 经常把 className 写成 class,把 studentName 写成 name,这里做统一修正
78
  */
79
  function normalizeQueryFields(query, collection) {
80
  if (!query || typeof query !== 'object') return query;
@@ -87,26 +85,86 @@ function normalizeQueryFields(query, collection) {
87
  for (const key in query) {
88
  let newKey = key;
89
 
90
- // 1. 修正班级字段: class -> className
91
  if (key === 'class') newKey = 'className';
92
 
93
- // 2. 修正名字字段: Score表里叫 studentName, Student表里叫 name
94
  if (collection === 'Score' || collection === 'Attendance') {
95
  if (key === 'name') newKey = 'studentName';
96
  }
97
 
98
- // 递归处理值 (例如 $or 数组内部的对象)
99
  newQuery[newKey] = normalizeQueryFields(query[key], collection);
100
  }
101
  return newQuery;
102
  }
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  /**
105
  * 3. 工具执行器 (Executor)
106
  */
107
  async function executeMongoTool(functionCall, user, role, schoolId) {
108
  let args = functionCall.args;
109
- // 兼容 OpenAI 格式
110
  if (typeof functionCall.arguments === 'string') {
111
  try {
112
  args = JSON.parse(functionCall.arguments);
@@ -118,20 +176,23 @@ async function executeMongoTool(functionCall, user, role, schoolId) {
118
 
119
  const { collection, filter = {}, limit = 5 } = args || {};
120
 
121
- // 🛠️ 关键修复:在注入安全字段前,先修正 AI 的字段命名错误
122
  const normalizedFilter = normalizeQueryFields(filter, collection);
123
 
124
- // 🛡️ 安全注入
125
- const safeFilter = injectSecurityFilter(normalizedFilter, user, role, schoolId);
 
 
 
 
126
  const safeLimit = Math.min(Math.max(limit, 1), 20);
127
 
128
  // --- 🔍 MCP LOGGING ---
129
  console.log(`\n================= [MCP TOOL CALL] =================`);
130
  console.log(`🛠️ Tool: query_database`);
131
  console.log(`📂 Collection: ${collection}`);
132
- console.log(`📥 AI Params: ${JSON.stringify(filter)}`); // 原始
133
- console.log(`🔧 Normalized: ${JSON.stringify(normalizedFilter)}`); // 修正后
134
- console.log(`🔒 Safe Query: ${JSON.stringify(safeFilter)}`); // 最终
135
  console.log(`---------------------------------------------------`);
136
 
137
  try {
@@ -141,17 +202,10 @@ async function executeMongoTool(functionCall, user, role, schoolId) {
141
  switch (collection) {
142
  case "Student":
143
  fields = "name studentNo className gender flowerBalance seatNo -_id";
144
- // 模糊搜索支持
145
- if (safeFilter.name && !safeFilter.name.$regex) {
146
- safeFilter.name = { $regex: safeFilter.name, $options: 'i' };
147
- }
148
  result = await Student.find(safeFilter).select(fields).limit(safeLimit).lean();
149
  break;
150
  case "Score":
151
  fields = "studentName courseName score type examName -_id";
152
- if (safeFilter.studentName && !safeFilter.studentName.$regex) {
153
- safeFilter.studentName = { $regex: safeFilter.studentName, $options: 'i' };
154
- }
155
  result = await Score.find(safeFilter).select(fields).sort({ _id: -1 }).limit(safeLimit).lean();
156
  break;
157
  case "Attendance":
@@ -171,12 +225,12 @@ async function executeMongoTool(functionCall, user, role, schoolId) {
171
  if (result.length > 0) {
172
  console.log(`📄 Sample: ${JSON.stringify(result[0])}`);
173
  } else {
174
- console.log(`⚠️ No records found. Hint: Check if schoolId matches.`);
175
  }
176
  console.log(`===================================================\n`);
177
 
178
  if (result.length === 0) {
179
- return { info: "未找到符合条件的数据。请确认查询条件是否准(如姓名是否正确)。" };
180
  }
181
  return result;
182
 
 
58
  return safeFilter;
59
  }
60
 
 
61
  if (role === 'TEACHER') {
62
  // 暂时不做强制过滤,依赖业务层逻辑
63
  }
 
73
 
74
  /**
75
  * 辅助:递归修正查询字段名 (Deep Normalization)
 
76
  */
77
  function normalizeQueryFields(query, collection) {
78
  if (!query || typeof query !== 'object') return query;
 
85
  for (const key in query) {
86
  let newKey = key;
87
 
88
+ // 1. 修正班级字段
89
  if (key === 'class') newKey = 'className';
90
 
91
+ // 2. 修正名字字段
92
  if (collection === 'Score' || collection === 'Attendance') {
93
  if (key === 'name') newKey = 'studentName';
94
  }
95
 
96
+ // 递归处理值
97
  newQuery[newKey] = normalizeQueryFields(query[key], collection);
98
  }
99
  return newQuery;
100
  }
101
 
102
+ /**
103
+ * 🌟 核心增强:智能模糊查询构建器
104
+ * 解决 "四年级6班" 查不到 "四年级(6)班" 的问题
105
+ */
106
+ function buildFuzzyQuery(filter) {
107
+ const fuzzyFilter = { ...filter };
108
+
109
+ // 辅助:转义正则特殊字符
110
+ const escapeRegExp = (string) => {
111
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
112
+ };
113
+
114
+ // 1. 处理 className (最常见的问题)
115
+ if (fuzzyFilter.className && typeof fuzzyFilter.className === 'string') {
116
+ const rawClass = fuzzyFilter.className;
117
+
118
+ // 提取年级和班级号 (例如: "四年级6班" -> grade="四年级", num="6")
119
+ // 支持中文数字 (一二三) 和 阿拉伯数字
120
+ const match = rawClass.match(/^(.+?级).*?(\d+|[一二三四五六七八九十]+).*?班$/);
121
+
122
+ if (match) {
123
+ const gradePart = match[1]; // 四年级
124
+ const numPart = match[2]; // 6 或 六
125
+
126
+ // 构建宽容的正则:匹配年级 + 任意字符(如括号) + 数字 + 任意字符 + 班
127
+ // 这样 "四年级6班" 可以匹配 "四年级(6)班", "四年级(6)班", "四年级六班"
128
+ fuzzyFilter.className = {
129
+ $regex: new RegExp(`^${escapeRegExp(gradePart)}.*(${numPart}|${convertNum(numPart)}).*班$`),
130
+ $options: 'i'
131
+ };
132
+ } else {
133
+ // 如果正则没解析出来,至少做个包含匹配,且忽略括号
134
+ // 比如搜 "1班",能匹配 "(1)班"
135
+ fuzzyFilter.className = { $regex: escapeRegExp(rawClass).replace(/[\(\)()]/g, '.?'), $options: 'i' };
136
+ }
137
+ }
138
+
139
+ // 2. 处理姓名 (模糊匹配)
140
+ const nameFields = ['name', 'studentName', 'teacherName'];
141
+ nameFields.forEach(field => {
142
+ if (fuzzyFilter[field] && typeof fuzzyFilter[field] === 'string') {
143
+ // 支持只搜 "张三" 匹配 "张三丰"
144
+ fuzzyFilter[field] = { $regex: escapeRegExp(fuzzyFilter[field]), $options: 'i' };
145
+ }
146
+ });
147
+
148
+ // 3. 处理 $or 数组中的模糊匹配 (递归)
149
+ if (fuzzyFilter.$or && Array.isArray(fuzzyFilter.$or)) {
150
+ fuzzyFilter.$or = fuzzyFilter.$or.map(subFilter => buildFuzzyQuery(subFilter));
151
+ }
152
+
153
+ return fuzzyFilter;
154
+ }
155
+
156
+ // 简单的数字互转辅助
157
+ function convertNum(n) {
158
+ const map = {'1':'一','2':'二','3':'三','4':'四','5':'五','6':'六','7':'七','8':'八','9':'九','10':'十',
159
+ '一':'1','二':'2','三':'3','四':'4','五':'5','六':'6','七':'7','八':'8','九':'9','十':'10'};
160
+ return map[n] || n;
161
+ }
162
+
163
  /**
164
  * 3. 工具执行器 (Executor)
165
  */
166
  async function executeMongoTool(functionCall, user, role, schoolId) {
167
  let args = functionCall.args;
 
168
  if (typeof functionCall.arguments === 'string') {
169
  try {
170
  args = JSON.parse(functionCall.arguments);
 
176
 
177
  const { collection, filter = {}, limit = 5 } = args || {};
178
 
179
+ // 1. 字段修正 (Key Normalization)
180
  const normalizedFilter = normalizeQueryFields(filter, collection);
181
 
182
+ // 2. 🛡️ 安全注入
183
+ let safeFilter = injectSecurityFilter(normalizedFilter, user, role, schoolId);
184
+
185
+ // 3. 🧠 智能模糊处理 (Value Normalization) - 关键修改
186
+ safeFilter = buildFuzzyQuery(safeFilter);
187
+
188
  const safeLimit = Math.min(Math.max(limit, 1), 20);
189
 
190
  // --- 🔍 MCP LOGGING ---
191
  console.log(`\n================= [MCP TOOL CALL] =================`);
192
  console.log(`🛠️ Tool: query_database`);
193
  console.log(`📂 Collection: ${collection}`);
194
+ console.log(`📥 AI Params: ${JSON.stringify(filter)}`);
195
+ console.log(`🔒 Safe Query: ${JSON.stringify(safeFilter)}`);
 
196
  console.log(`---------------------------------------------------`);
197
 
198
  try {
 
202
  switch (collection) {
203
  case "Student":
204
  fields = "name studentNo className gender flowerBalance seatNo -_id";
 
 
 
 
205
  result = await Student.find(safeFilter).select(fields).limit(safeLimit).lean();
206
  break;
207
  case "Score":
208
  fields = "studentName courseName score type examName -_id";
 
 
 
209
  result = await Score.find(safeFilter).select(fields).sort({ _id: -1 }).limit(safeLimit).lean();
210
  break;
211
  case "Attendance":
 
225
  if (result.length > 0) {
226
  console.log(`📄 Sample: ${JSON.stringify(result[0])}`);
227
  } else {
228
+ console.log(`⚠️ No records found.`);
229
  }
230
  console.log(`===================================================\n`);
231
 
232
  if (result.length === 0) {
233
+ return { info: "未找到符合条件的数据。请尝试放宽查询条件,或认班级/姓名格式(例如包含括号)。" };
234
  }
235
  return result;
236
 
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, StopCircle, Trash2 } from 'lucide-react';
5
  import ReactMarkdown from 'react-markdown';
6
  import remarkGfm from 'remark-gfm';
7
  import { blobToBase64, base64ToUint8Array, decodePCM, cleanTextForTTS } from '../../utils/mediaHelpers';
@@ -34,8 +34,10 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
34
  const [inputMode, setInputMode] = useState<'text' | 'audio'>('text');
35
  const [isChatProcessing, setIsChatProcessing] = useState(false);
36
  const [isChatRecording, setIsChatRecording] = useState(false);
37
- const [generatingAudioId, setGeneratingAudioId] = useState<string | null>(null);
38
  const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
 
 
 
39
 
40
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
41
  const audioChunksRef = useRef<Blob[]>([]);
@@ -68,8 +70,8 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
68
 
69
  // Scroll to bottom
70
  useEffect(() => {
71
- messagesEndRef.current?.scrollIntoView({ behavior: isChatProcessing ? 'auto' : 'smooth', block: 'end' });
72
- }, [messages, isChatProcessing, generatingAudioId]);
73
 
74
  const stopPlayback = () => {
75
  if (currentSourceRef.current) {
@@ -92,30 +94,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
92
  window.speechSynthesis.speak(utterance);
93
  };
94
 
95
- const playPCMAudio = async (base64Audio: string) => {
96
- stopPlayback();
97
- try {
98
- if (!audioContextRef.current) {
99
- // @ts-ignore
100
- const AudioCtor = window.AudioContext || window.webkitAudioContext;
101
- audioContextRef.current = new AudioCtor();
102
- }
103
- if (audioContextRef.current?.state === 'suspended') {
104
- await audioContextRef.current.resume();
105
- }
106
- const bytes = base64ToUint8Array(base64Audio);
107
- const audioBuffer = decodePCM(bytes, audioContextRef.current!);
108
- const source = audioContextRef.current!.createBufferSource();
109
- source.buffer = audioBuffer;
110
- source.connect(audioContextRef.current!.destination);
111
- source.start(0);
112
- currentSourceRef.current = source;
113
- } catch (e) {
114
- console.error("Audio playback error", e);
115
- setToast({ show: true, message: '语音播放失败', type: 'error' });
116
- }
117
- };
118
-
119
  const startRecording = async () => {
120
  try {
121
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
@@ -153,7 +131,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
153
  const handleChatSubmit = async (text?: string, audioBase64?: string) => {
154
  if (!text && !audioBase64) return;
155
  stopPlayback();
156
- setGeneratingAudioId(null);
157
 
158
  const historyPayload = messages.filter(m => m.id !== 'welcome').map(m => ({ role: m.role, text: m.text }));
159
 
@@ -164,15 +141,21 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
164
  isAudioMessage: !!audioBase64,
165
  timestamp: Date.now()
166
  };
 
167
  const newAiMsgId = (Date.now() + 1).toString();
 
168
  const newAiMsg: AIChatMessage = {
169
  id: newAiMsgId,
170
  role: 'model',
171
  text: '',
172
- timestamp: Date.now()
 
173
  };
174
 
175
  setMessages(prev => [...prev, newUserMsg, newAiMsg]);
 
 
 
176
  setTextInput('');
177
  setIsChatProcessing(true);
178
 
@@ -199,6 +182,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
199
  while (true) {
200
  const { done, value } = await reader.read();
201
  if (done) break;
 
202
  buffer += decoder.decode(value, { stream: true });
203
  const parts = buffer.split('\n\n');
204
  buffer = parts.pop() || '';
@@ -206,44 +190,44 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
206
  for (const line of parts) {
207
  if (line.startsWith('data: ')) {
208
  const jsonStr = line.replace('data: ', '').trim();
209
- if (jsonStr === '[DONE]') break;
210
  try {
211
  const data = JSON.parse(jsonStr);
212
 
213
- if (data.status === 'tts') {
214
- setGeneratingAudioId(newAiMsgId);
215
- }
216
-
217
- if (data.text) {
218
- aiTextAccumulated += data.text;
219
  setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: aiTextAccumulated } : m));
 
 
 
 
 
 
 
 
 
220
  }
221
- if (data.audio) {
222
- setGeneratingAudioId(null);
223
- setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, audio: data.audio } : m));
224
- playPCMAudio(data.audio);
225
- }
226
- if (data.ttsSkipped) {
227
- setGeneratingAudioId(null);
228
- setToast({ show: true, message: 'AI 语音额度已用尽,已切换至本地语音播报', type: 'error' });
229
- speakWithBrowser(aiTextAccumulated);
230
  }
231
- if (data.error) {
232
- setGeneratingAudioId(null);
233
- setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: `⚠️ 错误: ${data.message || '未知错误'}` } : m));
234
  }
235
  } catch (e) {}
236
  }
237
  }
238
  }
239
  } catch (error: any) {
240
- setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: '抱歉,连接断开或发生错误,请重试。' } : m));
241
  } finally {
242
  setIsChatProcessing(false);
243
- setGeneratingAudioId(null);
244
  }
245
  };
246
 
 
 
 
 
247
  const clearHistory = () => {
248
  setMessages([{
249
  id: 'welcome',
@@ -269,19 +253,39 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
269
  <div className={`w-10 h-10 rounded-full flex items-center justify-center shrink-0 ${msg.role === 'model' ? 'bg-blue-100 text-blue-600' : 'bg-gray-200 text-gray-600'}`}>
270
  {msg.role === 'model' ? <Sparkles size={20}/> : <Bot size={20}/>}
271
  </div>
272
- <div className={`max-w-[80%] p-3 rounded-2xl text-sm overflow-hidden ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white border border-gray-200 text-gray-800 rounded-tl-none shadow-sm'}`}>
273
- <div className="markdown-body"><ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.text || ''}</ReactMarkdown></div>
274
- {msg.role === 'model' && !msg.text && isChatProcessing && <div className="flex items-center gap-2 text-gray-400 py-1"><Loader2 className="animate-spin" size={14}/><span className="text-xs">思考中...</span></div>}
275
 
276
- {/* Audio Generating Indicator */}
277
- {msg.id === generatingAudioId && (
278
- <div className="flex items-center gap-2 text-purple-600 py-2 animate-pulse mt-1 border-t border-purple-100 pt-2">
279
- <Loader2 className="animate-spin" size={14}/>
280
- <span className="text-xs font-bold">正在生成语音回复...</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  </div>
282
  )}
283
 
284
- {msg.audio ? (<button onClick={() => playPCMAudio(msg.audio!)} className="mt-2 flex items-center gap-2 text-xs bg-blue-50 text-blue-600 px-3 py-1.5 rounded-full hover:bg-blue-100 border border-blue-100 transition-colors w-fit"><Volume2 size={14}/> 播放语音 (AI)</button>) : (msg.role === 'model' && msg.text && !isChatProcessing && !generatingAudioId) && (<button onClick={() => speakWithBrowser(msg.text!)} className="mt-2 flex items-center gap-2 text-xs bg-gray-50 text-gray-600 px-3 py-1.5 rounded-full hover:bg-gray-100 border border-gray-200 transition-colors w-fit"><Volume2 size={14}/> 朗读 (本地)</button>)}
 
 
 
 
 
285
  </div>
286
  </div>
287
  ))}
 
1
 
2
  import React, { useState, useRef, useEffect } from 'react';
3
  import { AIChatMessage, User } from '../../types';
4
+ import { Bot, Mic, Square, Volume2, Send, Sparkles, Loader2, StopCircle, Trash2, BrainCircuit, ChevronDown, ChevronRight } from 'lucide-react';
5
  import ReactMarkdown from 'react-markdown';
6
  import remarkGfm from 'remark-gfm';
7
  import { blobToBase64, base64ToUint8Array, decodePCM, cleanTextForTTS } from '../../utils/mediaHelpers';
 
34
  const [inputMode, setInputMode] = useState<'text' | 'audio'>('text');
35
  const [isChatProcessing, setIsChatProcessing] = useState(false);
36
  const [isChatRecording, setIsChatRecording] = useState(false);
 
37
  const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
38
+
39
+ // State to toggle thoughts visibility per message
40
+ const [expandedThoughts, setExpandedThoughts] = useState<Record<string, boolean>>({});
41
 
42
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
43
  const audioChunksRef = useRef<Blob[]>([]);
 
70
 
71
  // Scroll to bottom
72
  useEffect(() => {
73
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
74
+ }, [messages, isChatProcessing]);
75
 
76
  const stopPlayback = () => {
77
  if (currentSourceRef.current) {
 
94
  window.speechSynthesis.speak(utterance);
95
  };
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  const startRecording = async () => {
98
  try {
99
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
 
131
  const handleChatSubmit = async (text?: string, audioBase64?: string) => {
132
  if (!text && !audioBase64) return;
133
  stopPlayback();
 
134
 
135
  const historyPayload = messages.filter(m => m.id !== 'welcome').map(m => ({ role: m.role, text: m.text }));
136
 
 
141
  isAudioMessage: !!audioBase64,
142
  timestamp: Date.now()
143
  };
144
+
145
  const newAiMsgId = (Date.now() + 1).toString();
146
+ // Init with empty thoughts array
147
  const newAiMsg: AIChatMessage = {
148
  id: newAiMsgId,
149
  role: 'model',
150
  text: '',
151
+ timestamp: Date.now(),
152
+ thoughts: []
153
  };
154
 
155
  setMessages(prev => [...prev, newUserMsg, newAiMsg]);
156
+ // Auto-expand thoughts for new message
157
+ setExpandedThoughts(prev => ({...prev, [newAiMsgId]: true}));
158
+
159
  setTextInput('');
160
  setIsChatProcessing(true);
161
 
 
182
  while (true) {
183
  const { done, value } = await reader.read();
184
  if (done) break;
185
+
186
  buffer += decoder.decode(value, { stream: true });
187
  const parts = buffer.split('\n\n');
188
  buffer = parts.pop() || '';
 
190
  for (const line of parts) {
191
  if (line.startsWith('data: ')) {
192
  const jsonStr = line.replace('data: ', '').trim();
 
193
  try {
194
  const data = JSON.parse(jsonStr);
195
 
196
+ // REAL STREAMING HANDLING
197
+ if (data.type === 'text') {
198
+ aiTextAccumulated += data.content;
 
 
 
199
  setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: aiTextAccumulated } : m));
200
+ }
201
+ else if (data.type === 'thought') {
202
+ setMessages(prev => prev.map(m => {
203
+ if (m.id === newAiMsgId) {
204
+ const oldThoughts = m.thoughts || [];
205
+ return { ...m, thoughts: [...oldThoughts, data.content] };
206
+ }
207
+ return m;
208
+ }));
209
  }
210
+ else if (data.type === 'error') {
211
+ setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: `⚠️ 错误: ${data.message}` } : m));
 
 
 
 
 
 
 
212
  }
213
+ else if (data.type === 'done') {
214
+ break;
 
215
  }
216
  } catch (e) {}
217
  }
218
  }
219
  }
220
  } catch (error: any) {
221
+ setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: '抱歉,连接断开或发生错误。' } : m));
222
  } finally {
223
  setIsChatProcessing(false);
 
224
  }
225
  };
226
 
227
+ const toggleThoughts = (msgId: string) => {
228
+ setExpandedThoughts(prev => ({...prev, [msgId]: !prev[msgId]}));
229
+ };
230
+
231
  const clearHistory = () => {
232
  setMessages([{
233
  id: 'welcome',
 
253
  <div className={`w-10 h-10 rounded-full flex items-center justify-center shrink-0 ${msg.role === 'model' ? 'bg-blue-100 text-blue-600' : 'bg-gray-200 text-gray-600'}`}>
254
  {msg.role === 'model' ? <Sparkles size={20}/> : <Bot size={20}/>}
255
  </div>
256
+ <div className={`max-w-[85%] flex flex-col items-start ${msg.role === 'user' ? 'items-end' : ''}`}>
 
 
257
 
258
+ {/* Chain of Thought / Tool Logs */}
259
+ {msg.thoughts && msg.thoughts.length > 0 && (
260
+ <div className="mb-2 w-full max-w-md">
261
+ <div
262
+ onClick={() => toggleThoughts(msg.id)}
263
+ className="flex items-center gap-2 text-xs text-gray-500 bg-gray-50 border border-gray-200 rounded-lg px-3 py-1.5 cursor-pointer hover:bg-gray-100 transition-colors w-fit"
264
+ >
265
+ <BrainCircuit size={14} className={isChatProcessing && msg.id === messages[messages.length-1].id ? "animate-pulse text-purple-500" : "text-gray-400"}/>
266
+ <span>{isChatProcessing && msg.id === messages[messages.length-1].id ? '深度思考 & 工具调用中...' : '思维链 / 系统日志'}</span>
267
+ {expandedThoughts[msg.id] ? <ChevronDown size={14}/> : <ChevronRight size={14}/>}
268
+ </div>
269
+
270
+ {expandedThoughts[msg.id] && (
271
+ <div className="mt-1 bg-gray-50 border border-gray-100 rounded-lg p-3 text-xs font-mono text-gray-600 space-y-1 animate-in slide-in-from-top-1">
272
+ {msg.thoughts.map((t, idx) => (
273
+ <div key={idx} className="flex gap-2 border-l-2 border-gray-200 pl-2">
274
+ <span className="text-gray-400 select-none">[{idx+1}]</span>
275
+ <span className="whitespace-pre-wrap">{t}</span>
276
+ </div>
277
+ ))}
278
+ </div>
279
+ )}
280
  </div>
281
  )}
282
 
283
+ {/* Message Bubble */}
284
+ <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'}`}>
285
+ <div className="markdown-body"><ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.text || ''}</ReactMarkdown></div>
286
+ {msg.role === 'model' && !msg.text && isChatProcessing && <div className="flex items-center gap-2 text-gray-400 py-1"><Loader2 className="animate-spin" size={14}/><span className="text-xs">组织语言中...</span></div>}
287
+ {(msg.role === 'model' && msg.text && !isChatProcessing) && (<button onClick={() => speakWithBrowser(msg.text!)} className="mt-2 flex items-center gap-2 text-xs bg-gray-50 text-gray-600 px-3 py-1.5 rounded-full hover:bg-gray-100 border border-gray-200 transition-colors w-fit"><Volume2 size={14}/> 朗读</button>)}
288
+ </div>
289
  </div>
290
  </div>
291
  ))}
types.ts CHANGED
@@ -388,4 +388,5 @@ export interface AIChatMessage {
388
  audio?: string;
389
  isAudioMessage?: boolean;
390
  timestamp: number;
 
391
  }
 
388
  audio?: string;
389
  isAudioMessage?: boolean;
390
  timestamp: number;
391
+ thoughts?: string[]; // Chain of Thought / Tool execution logs
392
  }