dvc890 commited on
Commit
6f8a187
·
verified ·
1 Parent(s): ac70ee9

Upload 62 files

Browse files
Files changed (2) hide show
  1. ai-routes.js +3 -1
  2. components/ai/ChatPanel.tsx +26 -3
ai-routes.js CHANGED
@@ -373,6 +373,8 @@ router.post('/chat', checkAIAccess, async (req, res) => {
373
  }, res);
374
 
375
  if (answerText) {
 
 
376
  try {
377
  const { GoogleGenAI } = await import("@google/genai");
378
  const keys = await getKeyPool('gemini');
@@ -487,4 +489,4 @@ router.post('/evaluate', checkAIAccess, async (req, res) => {
487
  }
488
  });
489
 
490
- module.exports = router;
 
373
  }, res);
374
 
375
  if (answerText) {
376
+ // Signal that text generation is done and TTS is starting
377
+ res.write(`data: ${JSON.stringify({ status: 'tts' })}\n\n`);
378
  try {
379
  const { GoogleGenAI } = await import("@google/genai");
380
  const keys = await getKeyPool('gemini');
 
489
  }
490
  });
491
 
492
+ module.exports = router;
components/ai/ChatPanel.tsx CHANGED
@@ -34,6 +34,7 @@ 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 [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
38
 
39
  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
@@ -68,7 +69,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
68
  // Scroll to bottom
69
  useEffect(() => {
70
  messagesEndRef.current?.scrollIntoView({ behavior: isChatProcessing ? 'auto' : 'smooth', block: 'end' });
71
- }, [messages, isChatProcessing]);
72
 
73
  const stopPlayback = () => {
74
  if (currentSourceRef.current) {
@@ -152,6 +153,8 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
152
  const handleChatSubmit = async (text?: string, audioBase64?: string) => {
153
  if (!text && !audioBase64) return;
154
  stopPlayback();
 
 
155
  const historyPayload = messages.filter(m => m.id !== 'welcome').map(m => ({ role: m.role, text: m.text }));
156
 
157
  const newUserMsg: AIChatMessage = {
@@ -206,19 +209,27 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
206
  if (jsonStr === '[DONE]') break;
207
  try {
208
  const data = JSON.parse(jsonStr);
 
 
 
 
 
209
  if (data.text) {
210
  aiTextAccumulated += data.text;
211
  setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: aiTextAccumulated } : m));
212
  }
213
  if (data.audio) {
 
214
  setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, audio: data.audio } : m));
215
  playPCMAudio(data.audio);
216
  }
217
  if (data.ttsSkipped) {
 
218
  setToast({ show: true, message: 'AI 语音额度已用尽,已切换至本地语音播报', type: 'error' });
219
  speakWithBrowser(aiTextAccumulated);
220
  }
221
  if (data.error) {
 
222
  setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: `⚠️ 错误: ${data.message || '未知错误'}` } : m));
223
  }
224
  } catch (e) {}
@@ -227,7 +238,10 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
227
  }
228
  } catch (error: any) {
229
  setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: '抱歉,连接断开或发生错误,请重试。' } : m));
230
- } finally { setIsChatProcessing(false); }
 
 
 
231
  };
232
 
233
  const clearHistory = () => {
@@ -258,7 +272,16 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
258
  <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'}`}>
259
  <div className="markdown-body"><ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.text || ''}</ReactMarkdown></div>
260
  {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>}
261
- {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) && (<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>)}
 
 
 
 
 
 
 
 
 
262
  </div>
263
  </div>
264
  ))}
 
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);
 
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) {
 
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
 
160
  const newUserMsg: AIChatMessage = {
 
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) {}
 
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 = () => {
 
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
  ))}