Spaces:
Runtime error
Runtime error
Upload 62 files
Browse files- ai-routes.js +3 -1
- 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 {
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
))}
|