dvc890 commited on
Commit
f4d1cb8
·
verified ·
1 Parent(s): 62849dd

Upload 67 files

Browse files
components/ai/ChatPanel.tsx CHANGED
@@ -48,7 +48,11 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
48
  const audioContextRef = useRef<AudioContext | null>(null);
49
  const currentSourceRef = useRef<AudioBufferSourceNode | null>(null);
50
  const messagesEndRef = useRef<HTMLDivElement>(null);
 
51
  const fileInputRef = useRef<HTMLInputElement>(null);
 
 
 
52
 
53
  // Initialize AudioContext
54
  useEffect(() => {
@@ -73,8 +77,22 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
73
  } catch (e) {}
74
  }, [messages]);
75
 
 
76
  useEffect(() => {
77
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  }, [messages, isChatProcessing, isThinkingExpanded]);
79
 
80
  const stopPlayback = () => {
@@ -86,6 +104,15 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
86
  setPlayingMessageId(null);
87
  };
88
 
 
 
 
 
 
 
 
 
 
89
  const playAudio = async (msg: AIChatMessage) => {
90
  stopPlayback();
91
  // 1. Try playing pre-generated audio (Gemini TTS)
@@ -188,6 +215,9 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
188
  }
189
 
190
  setIsChatProcessing(true);
 
 
 
191
 
192
  // Fix: Use UUID to avoid collision
193
  const newAiMsgId = crypto.randomUUID();
@@ -208,6 +238,12 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
208
  const newAiMsg: AIChatMessage = { id: newAiMsgId, role: 'model', text: '', thought: '', timestamp: Date.now(), isSearching: enableSearch };
209
 
210
  setMessages(prev => [...prev, newUserMsg, newAiMsg]);
 
 
 
 
 
 
211
  if (enableThinking) setIsThinkingExpanded(prev => ({ ...prev, [newAiMsgId]: true }));
212
 
213
  const response = await fetch('/api/ai/chat', {
@@ -225,7 +261,8 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
225
  history: messages.filter(m => m.id !== 'welcome').map(m => ({ role: m.role, text: m.text })),
226
  enableThinking,
227
  enableSearch
228
- })
 
229
  });
230
 
231
  if (!response.ok) throw new Error(response.statusText);
@@ -289,13 +326,18 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
289
  }
290
  }
291
  }
292
- // Final cleanup
293
- setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, isSearching: false } : m));
294
 
295
  } catch (error: any) {
296
- setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: '抱歉,连接断开或发生错误。', isSearching: false } : m));
 
 
 
 
297
  } finally {
298
- setIsChatProcessing(false);
 
 
 
299
  }
300
  };
301
 
@@ -312,8 +354,8 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
312
  <Trash2 size={14}/> 清除
313
  </button>
314
  </div>
315
- {/* Chat History */}
316
- <div className="flex-1 overflow-y-auto p-4 space-y-4 pb-4 custom-scrollbar">
317
  {messages.map(msg => (
318
  <div key={msg.id} className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
319
  <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'}`}>
@@ -478,9 +520,13 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
478
  )}
479
  </div>
480
  {/* Right Actions */}
481
- {(textInput.trim() || audioAttachment || selectedImages.length > 0) ? (
482
- <button onClick={handleSubmit} disabled={isChatProcessing} className="p-2 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-all shrink-0 shadow-sm disabled:opacity-50">
483
- {isChatProcessing ? <Loader2 className="animate-spin" size={20}/> : <Send size={20}/>}
 
 
 
 
484
  </button>
485
  ) : (
486
  <button
@@ -493,7 +539,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
493
  >
494
  {isRecording ? <StopCircle size={22}/> : <Mic size={22}/>}
495
  </button>
496
- )}
497
  </div>
498
  {isRecording && <div className="text-center text-xs text-red-500 font-bold animate-pulse">正在录音... 松开发送到输入框</div>}
499
  </div>
 
48
  const audioContextRef = useRef<AudioContext | null>(null);
49
  const currentSourceRef = useRef<AudioBufferSourceNode | null>(null);
50
  const messagesEndRef = useRef<HTMLDivElement>(null);
51
+ const scrollContainerRef = useRef<HTMLDivElement>(null); // New Ref for Scroll Container
52
  const fileInputRef = useRef<HTMLInputElement>(null);
53
+
54
+ // Abort Controller Ref
55
+ const abortControllerRef = useRef<AbortController | null>(null);
56
 
57
  // Initialize AudioContext
58
  useEffect(() => {
 
77
  } catch (e) {}
78
  }, [messages]);
79
 
80
+ // SMART SCROLL LOGIC
81
  useEffect(() => {
82
+ if (!scrollContainerRef.current || !messagesEndRef.current) return;
83
+
84
+ const container = scrollContainerRef.current;
85
+ const { scrollTop, scrollHeight, clientHeight } = container;
86
+
87
+ // Threshold: If user is within 100px of the bottom, auto-scroll.
88
+ // Also force scroll if it's a brand new user message (we assume user wants to see their own msg)
89
+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 150;
90
+ const lastMsg = messages[messages.length - 1];
91
+ const isUserMsg = lastMsg?.role === 'user';
92
+
93
+ if (isNearBottom || (isUserMsg && !isChatProcessing)) {
94
+ messagesEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
95
+ }
96
  }, [messages, isChatProcessing, isThinkingExpanded]);
97
 
98
  const stopPlayback = () => {
 
104
  setPlayingMessageId(null);
105
  };
106
 
107
+ const handleStopGeneration = () => {
108
+ if (abortControllerRef.current) {
109
+ abortControllerRef.current.abort();
110
+ abortControllerRef.current = null;
111
+ setIsChatProcessing(false);
112
+ setToast({ show: true, message: '已停止生成', type: 'error' });
113
+ }
114
+ };
115
+
116
  const playAudio = async (msg: AIChatMessage) => {
117
  stopPlayback();
118
  // 1. Try playing pre-generated audio (Gemini TTS)
 
215
  }
216
 
217
  setIsChatProcessing(true);
218
+
219
+ // Init Abort Controller
220
+ abortControllerRef.current = new AbortController();
221
 
222
  // Fix: Use UUID to avoid collision
223
  const newAiMsgId = crypto.randomUUID();
 
238
  const newAiMsg: AIChatMessage = { id: newAiMsgId, role: 'model', text: '', thought: '', timestamp: Date.now(), isSearching: enableSearch };
239
 
240
  setMessages(prev => [...prev, newUserMsg, newAiMsg]);
241
+
242
+ // Force scroll to bottom when new user message is added
243
+ setTimeout(() => {
244
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
245
+ }, 100);
246
+
247
  if (enableThinking) setIsThinkingExpanded(prev => ({ ...prev, [newAiMsgId]: true }));
248
 
249
  const response = await fetch('/api/ai/chat', {
 
261
  history: messages.filter(m => m.id !== 'welcome').map(m => ({ role: m.role, text: m.text })),
262
  enableThinking,
263
  enableSearch
264
+ }),
265
+ signal: abortControllerRef.current.signal
266
  });
267
 
268
  if (!response.ok) throw new Error(response.statusText);
 
326
  }
327
  }
328
  }
 
 
329
 
330
  } catch (error: any) {
331
+ if (error.name === 'AbortError') {
332
+ // Ignore abort errors
333
+ } else {
334
+ setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: '抱歉,连接断开或发生错误。', isSearching: false } : m));
335
+ }
336
  } finally {
337
+ // CRITICAL: Ensure states are cleared even if error occurs or stream finishes
338
+ setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, isSearching: false, isGeneratingAudio: false } : m));
339
+ setIsChatProcessing(false);
340
+ abortControllerRef.current = null;
341
  }
342
  };
343
 
 
354
  <Trash2 size={14}/> 清除
355
  </button>
356
  </div>
357
+ {/* Chat History with Ref for Scroll Container */}
358
+ <div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-4 space-y-4 pb-4 custom-scrollbar">
359
  {messages.map(msg => (
360
  <div key={msg.id} className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
361
  <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'}`}>
 
520
  )}
521
  </div>
522
  {/* Right Actions */}
523
+ {isChatProcessing ? (
524
+ <button onClick={handleStopGeneration} className="p-2 bg-red-500 text-white rounded-full hover:bg-red-600 transition-all shrink-0 shadow-sm animate-pulse" title="停止生成">
525
+ <Square size={20} fill="currentColor"/>
526
+ </button>
527
+ ) : ((textInput.trim() || audioAttachment || selectedImages.length > 0) ? (
528
+ <button onClick={handleSubmit} className="p-2 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-all shrink-0 shadow-sm disabled:opacity-50">
529
+ <Send size={20}/>
530
  </button>
531
  ) : (
532
  <button
 
539
  >
540
  {isRecording ? <StopCircle size={22}/> : <Mic size={22}/>}
541
  </button>
542
+ ))}
543
  </div>
544
  {isRecording && <div className="text-center text-xs text-red-500 font-bold animate-pulse">正在录音... 松开发送到输入框</div>}
545
  </div>
components/ai/WorkAssistantPanel.tsx CHANGED
@@ -1,7 +1,7 @@
1
 
2
  import React, { useState, useRef, useEffect } from 'react';
3
  import { AIChatMessage, User } from '../../types';
4
- import { Bot, Send, Sparkles, Loader2, Image as ImageIcon, X, Trash2, Brain, ChevronDown, ChevronRight, Copy, Check, FileText, Plus, Paperclip, File, Globe } from 'lucide-react';
5
  import ReactMarkdown from 'react-markdown';
6
  import remarkGfm from 'remark-gfm';
7
  import { compressImage } from '../../utils/mediaHelpers';
@@ -161,11 +161,26 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
161
  const [isThinkingExpanded, setIsThinkingExpanded] = useState<Record<string, boolean>>({});
162
 
163
  const messagesEndRef = useRef<HTMLDivElement>(null);
 
164
  const fileInputRef = useRef<HTMLInputElement>(null);
165
  const docInputRef = useRef<HTMLInputElement>(null);
 
166
 
 
167
  useEffect(() => {
168
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
 
 
 
 
 
 
 
 
 
 
 
 
169
  }, [messages, isProcessing, isThinkingExpanded]);
170
 
171
  const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -204,6 +219,15 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
204
  setToast({ show: true, message: '内容已复制', type: 'success' });
205
  };
206
 
 
 
 
 
 
 
 
 
 
207
  const handleSubmit = async () => {
208
  if ((!textInput.trim() && selectedImages.length === 0 && !docContent) || isProcessing) return;
209
 
@@ -217,6 +241,8 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
217
  setTextInput('');
218
  setSelectedImages([]);
219
  clearDoc();
 
 
220
 
221
  const userMsgId = crypto.randomUUID();
222
  const aiMsgId = crypto.randomUUID();
@@ -247,6 +273,11 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
247
 
248
  setMessages(prev => [...prev, newUserMsg, newAiMsg]);
249
 
 
 
 
 
 
250
  if (enableThinking) {
251
  setIsThinkingExpanded(prev => ({ ...prev, [aiMsgId]: true }));
252
  }
@@ -285,7 +316,8 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
285
  enableSearch,
286
  overrideSystemPrompt: dynamicSystemPrompt,
287
  disableAudio: true
288
- })
 
289
  });
290
 
291
  if (!response.ok) throw new Error(response.statusText);
@@ -332,13 +364,15 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
332
  }
333
  }
334
  }
335
- // Cleanup
336
- setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, isSearching: false } : m));
337
 
338
  } catch (error: any) {
339
- setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, text: `抱歉,处理失败: ${error.message}`, isSearching: false } : m));
 
 
340
  } finally {
 
341
  setIsProcessing(false);
 
342
  }
343
  };
344
 
@@ -463,8 +497,8 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
463
  </div>
464
  </div>
465
 
466
- {/* Chat Area */}
467
- <div className="flex-1 overflow-y-auto p-6 space-y-6 custom-scrollbar">
468
  {messages.length === 0 && (
469
  <div className="flex flex-col items-center justify-center h-full text-gray-400 opacity-60">
470
  <FileText size={48} className="mb-4 text-indigo-300"/>
@@ -616,13 +650,23 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
616
  onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }}
617
  />
618
 
619
- <button
620
- onClick={handleSubmit}
621
- disabled={(!textInput.trim() && selectedImages.length === 0 && !docFile) || isProcessing || docLoading}
622
- className={`p-3 rounded-xl transition-all shrink-0 shadow-sm ${(!textInput.trim() && selectedImages.length === 0 && !docFile) || isProcessing || docLoading ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-indigo-600 text-white hover:bg-indigo-700 hover:scale-105'}`}
623
- >
624
- {isProcessing || docLoading ? <Loader2 className="animate-spin" size={20}/> : <Send size={20}/>}
625
- </button>
 
 
 
 
 
 
 
 
 
 
626
  </div>
627
  <div className="flex justify-between text-xs text-gray-400 px-1">
628
  <span>* 支持上传 Word/PDF/Txt 解析内容</span>
 
1
 
2
  import React, { useState, useRef, useEffect } from 'react';
3
  import { AIChatMessage, User } from '../../types';
4
+ import { Bot, Send, Sparkles, Loader2, Image as ImageIcon, X, Trash2, Brain, ChevronDown, ChevronRight, Copy, Check, FileText, Plus, Paperclip, File, Globe, Square } from 'lucide-react';
5
  import ReactMarkdown from 'react-markdown';
6
  import remarkGfm from 'remark-gfm';
7
  import { compressImage } from '../../utils/mediaHelpers';
 
161
  const [isThinkingExpanded, setIsThinkingExpanded] = useState<Record<string, boolean>>({});
162
 
163
  const messagesEndRef = useRef<HTMLDivElement>(null);
164
+ const scrollContainerRef = useRef<HTMLDivElement>(null); // New Scroll Container
165
  const fileInputRef = useRef<HTMLInputElement>(null);
166
  const docInputRef = useRef<HTMLInputElement>(null);
167
+ const abortControllerRef = useRef<AbortController | null>(null);
168
 
169
+ // Smart Scroll
170
  useEffect(() => {
171
+ if (!scrollContainerRef.current || !messagesEndRef.current) return;
172
+
173
+ const container = scrollContainerRef.current;
174
+ const { scrollTop, scrollHeight, clientHeight } = container;
175
+
176
+ // Auto scroll if user is near bottom OR if it's a new user prompt (start of turn)
177
+ const isNearBottom = scrollHeight - scrollTop - clientHeight < 150;
178
+ const lastMsg = messages[messages.length - 1];
179
+ const isUserMsg = lastMsg?.role === 'user';
180
+
181
+ if (isNearBottom || (isUserMsg && !isProcessing)) {
182
+ messagesEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
183
+ }
184
  }, [messages, isProcessing, isThinkingExpanded]);
185
 
186
  const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
 
219
  setToast({ show: true, message: '内容已复制', type: 'success' });
220
  };
221
 
222
+ const handleStopGeneration = () => {
223
+ if (abortControllerRef.current) {
224
+ abortControllerRef.current.abort();
225
+ abortControllerRef.current = null;
226
+ setIsProcessing(false);
227
+ setToast({ show: true, message: '已停止生成', type: 'error' });
228
+ }
229
+ };
230
+
231
  const handleSubmit = async () => {
232
  if ((!textInput.trim() && selectedImages.length === 0 && !docContent) || isProcessing) return;
233
 
 
241
  setTextInput('');
242
  setSelectedImages([]);
243
  clearDoc();
244
+
245
+ abortControllerRef.current = new AbortController();
246
 
247
  const userMsgId = crypto.randomUUID();
248
  const aiMsgId = crypto.randomUUID();
 
273
 
274
  setMessages(prev => [...prev, newUserMsg, newAiMsg]);
275
 
276
+ // Force scroll to bottom on submit
277
+ setTimeout(() => {
278
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
279
+ }, 100);
280
+
281
  if (enableThinking) {
282
  setIsThinkingExpanded(prev => ({ ...prev, [aiMsgId]: true }));
283
  }
 
316
  enableSearch,
317
  overrideSystemPrompt: dynamicSystemPrompt,
318
  disableAudio: true
319
+ }),
320
+ signal: abortControllerRef.current.signal
321
  });
322
 
323
  if (!response.ok) throw new Error(response.statusText);
 
364
  }
365
  }
366
  }
 
 
367
 
368
  } catch (error: any) {
369
+ if (error.name !== 'AbortError') {
370
+ setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, text: `抱歉,处理失败: ${error.message}`, isSearching: false } : m));
371
+ }
372
  } finally {
373
+ setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, isSearching: false } : m));
374
  setIsProcessing(false);
375
+ abortControllerRef.current = null;
376
  }
377
  };
378
 
 
497
  </div>
498
  </div>
499
 
500
+ {/* Chat Area with Ref for Scroll Container */}
501
+ <div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-6 space-y-6 custom-scrollbar">
502
  {messages.length === 0 && (
503
  <div className="flex flex-col items-center justify-center h-full text-gray-400 opacity-60">
504
  <FileText size={48} className="mb-4 text-indigo-300"/>
 
650
  onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); } }}
651
  />
652
 
653
+ {isProcessing ? (
654
+ <button
655
+ onClick={handleStopGeneration}
656
+ className="p-3 bg-red-500 text-white rounded-xl shadow-sm hover:bg-red-600 transition-all shrink-0 animate-pulse"
657
+ title="停止生成"
658
+ >
659
+ <Square size={20} fill="currentColor"/>
660
+ </button>
661
+ ) : (
662
+ <button
663
+ onClick={handleSubmit}
664
+ disabled={(!textInput.trim() && selectedImages.length === 0 && !docFile) || isProcessing || docLoading}
665
+ className={`p-3 rounded-xl transition-all shrink-0 shadow-sm ${(!textInput.trim() && selectedImages.length === 0 && !docFile) || isProcessing || docLoading ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-indigo-600 text-white hover:bg-indigo-700 hover:scale-105'}`}
666
+ >
667
+ {isProcessing || docLoading ? <Loader2 className="animate-spin" size={20}/> : <Send size={20}/>}
668
+ </button>
669
+ )}
670
  </div>
671
  <div className="flex justify-between text-xs text-gray-400 px-1">
672
  <span>* 支持上传 Word/PDF/Txt 解析内容</span>