Spaces:
Running
Running
Upload 64 files
Browse files- ai-routes.js +126 -175
- ai-tools.js +75 -21
- components/ai/ChatPanel.tsx +64 -60
- 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 |
-
// ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 106 |
-
const
|
| 107 |
-
|
| 108 |
-
: ['GEMINI', 'OPENROUTER'];
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
//
|
| 136 |
-
let
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 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 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 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 |
-
|
| 242 |
res.end();
|
| 243 |
}
|
| 244 |
});
|
| 245 |
|
| 246 |
-
//
|
| 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. 修正班级字段
|
| 91 |
if (key === 'class') newKey = 'className';
|
| 92 |
|
| 93 |
-
// 2. 修正名字字段
|
| 94 |
if (collection === 'Score' || collection === 'Attendance') {
|
| 95 |
if (key === 'name') newKey = 'studentName';
|
| 96 |
}
|
| 97 |
|
| 98 |
-
// 递归处理值
|
| 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 |
-
//
|
| 122 |
const normalizedFilter = normalizeQueryFields(filter, collection);
|
| 123 |
|
| 124 |
-
// 🛡️ 安全注入
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(`
|
| 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.
|
| 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:
|
| 72 |
-
}, [messages, isChatProcessing
|
| 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 |
-
|
| 214 |
-
|
| 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.
|
| 222 |
-
|
| 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.
|
| 232 |
-
|
| 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: '抱歉,连接断开或发生错误
|
| 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-[
|
| 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 |
-
{/*
|
| 277 |
-
{msg.
|
| 278 |
-
<div className="
|
| 279 |
-
<
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
</div>
|
| 282 |
)}
|
| 283 |
|
| 284 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|