dvc890 commited on
Commit
b0770b8
·
verified ·
1 Parent(s): 151e61b

Upload 67 files

Browse files
components/Sidebar.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useMemo } from 'react';
2
  import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building, Gamepad2, CalendarCheck, UserCircle, MessageSquare, Bot, ArrowUp, ArrowDown, Save, UserCheck, Download, Smartphone } from 'lucide-react';
3
  import { UserRole } from '../types';
4
  import { api } from '../services/api';
@@ -49,17 +49,24 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
49
  const [installPrompt, setInstallPrompt] = useState<any>(null);
50
  const [isStandalone, setIsStandalone] = useState(false);
51
 
52
- // Capture PWA Prompt
53
- useEffect(() => {
54
- // 1. Check Standalone
55
  const checkStandalone = () => {
56
- const isStandaloneMode = window.matchMedia('(display-mode: standalone)').matches ||
57
- (window.navigator as any).standalone === true;
 
 
 
58
  setIsStandalone(isStandaloneMode);
59
  };
 
60
  checkStandalone();
61
  window.matchMedia('(display-mode: standalone)').addEventListener('change', checkStandalone);
 
 
62
 
 
 
63
  if ((window as any).deferredPrompt) {
64
  setInstallPrompt((window as any).deferredPrompt);
65
  }
@@ -71,10 +78,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
71
  };
72
 
73
  window.addEventListener('beforeinstallprompt', handler);
74
- return () => {
75
- window.removeEventListener('beforeinstallprompt', handler);
76
- window.matchMedia('(display-mode: standalone)').removeEventListener('change', checkStandalone);
77
- };
78
  }, []);
79
 
80
  const handleInstallClick = async () => {
@@ -199,22 +203,24 @@ export const Sidebar: React.FC<SidebarProps> = ({ currentView, onChangeView, use
199
  </div>
200
 
201
  <div className="p-4 border-t border-slate-700 shrink-0 space-y-2">
202
- {!isStandalone && installPrompt && (
203
- <button onClick={handleInstallClick} className="w-full flex items-center space-x-3 px-4 py-3 rounded-lg bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:shadow-lg transition-all duration-200 animate-in fade-in slide-in-from-bottom-2">
204
- <Download size={20} />
205
- <span className="font-bold">安装到桌面/手机</span>
206
- </button>
207
- )}
208
-
209
- {!isStandalone && !installPrompt && (
210
- <div className="lg:hidden px-4 py-2 bg-slate-800 rounded-lg text-[10px] text-slate-400 border border-slate-700 flex gap-2 items-start">
211
- <Smartphone size={14} className="mt-0.5 shrink-0"/>
212
- <div>
213
- 若未显示安装按钮:
214
- <br/>iOS: 点击分享 → 添加到主屏幕
215
- <br/>Android: 点击菜单 → 安装应用
 
216
  </div>
217
- </div>
 
218
  )}
219
 
220
  <div className="px-4 py-2 text-xs text-slate-500 flex justify-between">
 
1
+ import React, { useState, useEffect, useLayoutEffect } from 'react';
2
  import { LayoutDashboard, Users, BookOpen, GraduationCap, Settings, LogOut, FileText, School, UserCog, Palette, X, Building, Gamepad2, CalendarCheck, UserCircle, MessageSquare, Bot, ArrowUp, ArrowDown, Save, UserCheck, Download, Smartphone } from 'lucide-react';
3
  import { UserRole } from '../types';
4
  import { api } from '../services/api';
 
49
  const [installPrompt, setInstallPrompt] = useState<any>(null);
50
  const [isStandalone, setIsStandalone] = useState(false);
51
 
52
+ // Robust PWA Check
53
+ useLayoutEffect(() => {
 
54
  const checkStandalone = () => {
55
+ const isStandaloneMode =
56
+ window.matchMedia('(display-mode: standalone)').matches ||
57
+ (window.navigator as any).standalone === true ||
58
+ document.referrer.includes('android-app://');
59
+
60
  setIsStandalone(isStandaloneMode);
61
  };
62
+
63
  checkStandalone();
64
  window.matchMedia('(display-mode: standalone)').addEventListener('change', checkStandalone);
65
+ return () => window.matchMedia('(display-mode: standalone)').removeEventListener('change', checkStandalone);
66
+ }, []);
67
 
68
+ // Capture PWA Prompt
69
+ useEffect(() => {
70
  if ((window as any).deferredPrompt) {
71
  setInstallPrompt((window as any).deferredPrompt);
72
  }
 
78
  };
79
 
80
  window.addEventListener('beforeinstallprompt', handler);
81
+ return () => window.removeEventListener('beforeinstallprompt', handler);
 
 
 
82
  }, []);
83
 
84
  const handleInstallClick = async () => {
 
203
  </div>
204
 
205
  <div className="p-4 border-t border-slate-700 shrink-0 space-y-2">
206
+ {!isStandalone && (
207
+ <>
208
+ {installPrompt ? (
209
+ <button onClick={handleInstallClick} className="w-full flex items-center space-x-3 px-4 py-3 rounded-lg bg-gradient-to-r from-blue-600 to-indigo-600 text-white hover:shadow-lg transition-all duration-200 animate-in fade-in slide-in-from-bottom-2">
210
+ <Download size={20} />
211
+ <span className="font-bold">安装到桌面/手机</span>
212
+ </button>
213
+ ) : (
214
+ <div className="lg:hidden px-4 py-2 bg-slate-800 rounded-lg text-[10px] text-slate-400 border border-slate-700 flex gap-2 items-start">
215
+ <Smartphone size={14} className="mt-0.5 shrink-0"/>
216
+ <div>
217
+ 若未显示安装按钮:
218
+ <br/>iOS: 点击分享 → 添加到主屏幕
219
+ <br/>Android: 点击菜单 → 安装应用
220
+ </div>
221
  </div>
222
+ )}
223
+ </>
224
  )}
225
 
226
  <div className="px-4 py-2 text-xs text-slate-500 flex justify-between">
components/ai/ChatPanel.tsx CHANGED
@@ -48,10 +48,9 @@ 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 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
@@ -80,12 +79,8 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
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';
@@ -115,7 +110,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
115
 
116
  const playAudio = async (msg: AIChatMessage) => {
117
  stopPlayback();
118
- // 1. Try playing pre-generated audio (Gemini TTS)
119
  if (msg.audio) {
120
  try {
121
  if (!audioContextRef.current) {
@@ -141,7 +135,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
141
  }
142
  }
143
 
144
- // 2. Fallback to Browser TTS
145
  if (!msg.text) return;
146
  const cleanText = cleanTextForTTS(msg.text);
147
  const utterance = new SpeechSynthesisUtterance(cleanText);
@@ -153,7 +146,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
153
  };
154
 
155
  const startRecording = async () => {
156
- if (audioAttachment) return; // Already has audio
157
  try {
158
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
159
  const mediaRecorder = new MediaRecorder(stream);
@@ -179,7 +172,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
179
  mediaRecorderRef.current.onstop = async () => {
180
  const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
181
  const base64 = await blobToBase64(audioBlob);
182
- setAudioAttachment(base64); // Save as attachment, don't send yet
183
  mediaRecorderRef.current?.stream.getTracks().forEach(track => track.stop());
184
  };
185
  }
@@ -204,22 +197,17 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
204
  const currentAudio = audioAttachment;
205
  const currentImages = [...selectedImages];
206
 
207
- // Clear Inputs
208
  setTextInput('');
209
  setAudioAttachment(null);
210
  setSelectedImages([]);
211
 
212
- // Ensure Context Ready
213
  if (audioContextRef.current?.state === 'suspended') {
214
  try { await audioContextRef.current.resume(); } catch(e){}
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();
224
  const newUserMsgId = crypto.randomUUID();
225
 
@@ -239,7 +227,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
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);
@@ -293,7 +280,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
293
  setIsThinkingExpanded(prev => ({ ...prev, [newAiMsgId]: false }));
294
  }
295
  aiTextAccumulated += data.content;
296
- // Clear searching state when text arrives
297
  setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: aiTextAccumulated, isSearching: false } : m));
298
  }
299
  else if (data.type === 'thinking') {
@@ -301,7 +287,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
301
  setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, thought: aiThoughtAccumulated } : m));
302
  }
303
  else if (data.type === 'search') {
304
- // Enable search visual explicitly
305
  setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, isSearching: true } : m));
306
  }
307
  else if (data.type === 'status' && data.status === 'tts') {
@@ -328,13 +313,10 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
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;
@@ -354,7 +336,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
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' : ''}`}>
@@ -380,7 +362,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
380
  </div>
381
  )}
382
 
383
- {/* Search Status Bubble (Added) */}
384
  {msg.role === 'model' && msg.isSearching && (
385
  <div className="flex items-center gap-2 bg-blue-50 text-blue-600 px-3 py-2 rounded-xl mb-2 text-xs border border-blue-100 animate-pulse w-fit">
386
  <Globe size={14} className="animate-spin"/>
@@ -439,30 +420,24 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
439
  <div ref={messagesEndRef} />
440
  </div>
441
 
442
- {/* Input Area */}
443
  <div className="p-4 bg-white border-t border-gray-200 shrink-0 z-20">
444
  <div className="max-w-4xl mx-auto flex flex-col gap-2">
445
-
446
- {/* Toolbar */}
447
  <div className="flex justify-between items-center px-1">
448
  <div className="flex gap-3">
449
  <button
450
  onClick={() => setEnableThinking(!enableThinking)}
451
  className={`flex items-center gap-1 text-xs px-2 py-1 rounded-md border transition-all ${enableThinking ? 'bg-purple-50 text-purple-600 border-purple-200' : 'bg-gray-50 text-gray-500 border-transparent hover:bg-gray-100'}`}
452
- title="开启后AI将进行深度思考 (仅部分模型支持)"
453
  >
454
  <Brain size={14} className={enableThinking ? "fill-current" : ""}/> 深度思考
455
  </button>
456
  <button
457
  onClick={() => setEnableSearch(!enableSearch)}
458
  className={`flex items-center gap-1 text-xs px-2 py-1 rounded-md border transition-all ${enableSearch ? 'bg-blue-50 text-blue-600 border-blue-200' : 'bg-gray-50 text-gray-500 border-transparent hover:bg-gray-100'}`}
459
- title="开启后AI将联网搜索最新信息 (仅部分模型支持)"
460
  >
461
  <Globe size={14}/> 联网搜索
462
  </button>
463
  </div>
464
  </div>
465
- {/* Attachments Preview */}
466
  {(selectedImages.length > 0 || audioAttachment) && (
467
  <div className="flex gap-2 overflow-x-auto pb-2">
468
  {selectedImages.map((file, idx) => (
@@ -483,7 +458,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
483
  </div>
484
  )}
485
  <div className="flex items-end gap-2 bg-gray-100 p-2 rounded-2xl border border-gray-200">
486
- {/* Image Button */}
487
  <button onClick={() => fileInputRef.current?.click()} className="p-2 text-gray-500 hover:bg-white hover:text-blue-600 rounded-full transition-colors shrink-0">
488
  <ImageIcon size={22}/>
489
  </button>
@@ -494,9 +468,8 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
494
  ref={fileInputRef}
495
  className="hidden"
496
  onChange={handleImageSelect}
497
- onClick={(e) => (e.currentTarget.value = '')} // RESET VALUE
498
  />
499
- {/* Input Area */}
500
  <div className="flex-1 min-h-[40px] flex items-center">
501
  {audioAttachment ? (
502
  <div className="w-full text-center text-sm text-gray-400 italic bg-transparent">
@@ -518,13 +491,19 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
518
  />
519
  )}
520
  </div>
521
- {/* Right Actions */}
522
  {isChatProcessing ? (
523
  <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="停止生成">
524
  <Square size={20} fill="currentColor"/>
525
  </button>
526
  ) : ((textInput.trim() || audioAttachment || selectedImages.length > 0) ? (
527
- <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">
 
 
 
 
 
 
 
528
  <Send size={20}/>
529
  </button>
530
  ) : (
@@ -533,7 +512,7 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
533
  onMouseUp={stopRecording}
534
  onTouchStart={startRecording}
535
  onTouchEnd={stopRecording}
536
- onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }} // Prevent menu on long press
537
  className={`p-2 rounded-full transition-all shrink-0 select-none ${isRecording ? 'bg-red-500 text-white scale-110 shadow-lg ring-4 ring-red-200' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'}`}
538
  >
539
  {isRecording ? <StopCircle size={22}/> : <Mic size={22}/>}
@@ -545,4 +524,4 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ currentUser }) => {
545
  </div>
546
  </div>
547
  );
548
- };
 
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);
52
  const fileInputRef = useRef<HTMLInputElement>(null);
53
 
 
54
  const abortControllerRef = useRef<AbortController | null>(null);
55
 
56
  // Initialize AudioContext
 
79
  // SMART SCROLL LOGIC
80
  useEffect(() => {
81
  if (!scrollContainerRef.current || !messagesEndRef.current) return;
 
82
  const container = scrollContainerRef.current;
83
  const { scrollTop, scrollHeight, clientHeight } = container;
 
 
 
84
  const isNearBottom = scrollHeight - scrollTop - clientHeight < 150;
85
  const lastMsg = messages[messages.length - 1];
86
  const isUserMsg = lastMsg?.role === 'user';
 
110
 
111
  const playAudio = async (msg: AIChatMessage) => {
112
  stopPlayback();
 
113
  if (msg.audio) {
114
  try {
115
  if (!audioContextRef.current) {
 
135
  }
136
  }
137
 
 
138
  if (!msg.text) return;
139
  const cleanText = cleanTextForTTS(msg.text);
140
  const utterance = new SpeechSynthesisUtterance(cleanText);
 
146
  };
147
 
148
  const startRecording = async () => {
149
+ if (audioAttachment) return;
150
  try {
151
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
152
  const mediaRecorder = new MediaRecorder(stream);
 
172
  mediaRecorderRef.current.onstop = async () => {
173
  const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
174
  const base64 = await blobToBase64(audioBlob);
175
+ setAudioAttachment(base64);
176
  mediaRecorderRef.current?.stream.getTracks().forEach(track => track.stop());
177
  };
178
  }
 
197
  const currentAudio = audioAttachment;
198
  const currentImages = [...selectedImages];
199
 
 
200
  setTextInput('');
201
  setAudioAttachment(null);
202
  setSelectedImages([]);
203
 
 
204
  if (audioContextRef.current?.state === 'suspended') {
205
  try { await audioContextRef.current.resume(); } catch(e){}
206
  }
207
 
208
  setIsChatProcessing(true);
 
 
209
  abortControllerRef.current = new AbortController();
210
 
 
211
  const newAiMsgId = crypto.randomUUID();
212
  const newUserMsgId = crypto.randomUUID();
213
 
 
227
 
228
  setMessages(prev => [...prev, newUserMsg, newAiMsg]);
229
 
 
230
  setTimeout(() => {
231
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
232
  }, 100);
 
280
  setIsThinkingExpanded(prev => ({ ...prev, [newAiMsgId]: false }));
281
  }
282
  aiTextAccumulated += data.content;
 
283
  setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: aiTextAccumulated, isSearching: false } : m));
284
  }
285
  else if (data.type === 'thinking') {
 
287
  setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, thought: aiThoughtAccumulated } : m));
288
  }
289
  else if (data.type === 'search') {
 
290
  setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, isSearching: true } : m));
291
  }
292
  else if (data.type === 'status' && data.status === 'tts') {
 
313
  }
314
 
315
  } catch (error: any) {
316
+ if (error.name !== 'AbortError') {
 
 
317
  setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, text: '抱歉,连接断开或发生错误。', isSearching: false } : m));
318
  }
319
  } finally {
 
320
  setMessages(prev => prev.map(m => m.id === newAiMsgId ? { ...m, isSearching: false, isGeneratingAudio: false } : m));
321
  setIsChatProcessing(false);
322
  abortControllerRef.current = null;
 
336
  <Trash2 size={14}/> 清除
337
  </button>
338
  </div>
339
+
340
  <div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-4 space-y-4 pb-4 custom-scrollbar">
341
  {messages.map(msg => (
342
  <div key={msg.id} className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
 
362
  </div>
363
  )}
364
 
 
365
  {msg.role === 'model' && msg.isSearching && (
366
  <div className="flex items-center gap-2 bg-blue-50 text-blue-600 px-3 py-2 rounded-xl mb-2 text-xs border border-blue-100 animate-pulse w-fit">
367
  <Globe size={14} className="animate-spin"/>
 
420
  <div ref={messagesEndRef} />
421
  </div>
422
 
 
423
  <div className="p-4 bg-white border-t border-gray-200 shrink-0 z-20">
424
  <div className="max-w-4xl mx-auto flex flex-col gap-2">
 
 
425
  <div className="flex justify-between items-center px-1">
426
  <div className="flex gap-3">
427
  <button
428
  onClick={() => setEnableThinking(!enableThinking)}
429
  className={`flex items-center gap-1 text-xs px-2 py-1 rounded-md border transition-all ${enableThinking ? 'bg-purple-50 text-purple-600 border-purple-200' : 'bg-gray-50 text-gray-500 border-transparent hover:bg-gray-100'}`}
 
430
  >
431
  <Brain size={14} className={enableThinking ? "fill-current" : ""}/> 深度思考
432
  </button>
433
  <button
434
  onClick={() => setEnableSearch(!enableSearch)}
435
  className={`flex items-center gap-1 text-xs px-2 py-1 rounded-md border transition-all ${enableSearch ? 'bg-blue-50 text-blue-600 border-blue-200' : 'bg-gray-50 text-gray-500 border-transparent hover:bg-gray-100'}`}
 
436
  >
437
  <Globe size={14}/> 联网搜索
438
  </button>
439
  </div>
440
  </div>
 
441
  {(selectedImages.length > 0 || audioAttachment) && (
442
  <div className="flex gap-2 overflow-x-auto pb-2">
443
  {selectedImages.map((file, idx) => (
 
458
  </div>
459
  )}
460
  <div className="flex items-end gap-2 bg-gray-100 p-2 rounded-2xl border border-gray-200">
 
461
  <button onClick={() => fileInputRef.current?.click()} className="p-2 text-gray-500 hover:bg-white hover:text-blue-600 rounded-full transition-colors shrink-0">
462
  <ImageIcon size={22}/>
463
  </button>
 
468
  ref={fileInputRef}
469
  className="hidden"
470
  onChange={handleImageSelect}
471
+ onClick={(e) => (e.currentTarget.value = '')}
472
  />
 
473
  <div className="flex-1 min-h-[40px] flex items-center">
474
  {audioAttachment ? (
475
  <div className="w-full text-center text-sm text-gray-400 italic bg-transparent">
 
491
  />
492
  )}
493
  </div>
 
494
  {isChatProcessing ? (
495
  <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="停止生成">
496
  <Square size={20} fill="currentColor"/>
497
  </button>
498
  ) : ((textInput.trim() || audioAttachment || selectedImages.length > 0) ? (
499
+ <button
500
+ onClick={handleSubmit}
501
+ // FIX: Add onMouseDown/onTouchEnd to prevent focus loss issues on mobile
502
+ onMouseDown={(e) => e.preventDefault()}
503
+ onTouchEnd={(e) => { e.preventDefault(); handleSubmit(); }}
504
+ type="button"
505
+ className="p-2 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-all shrink-0 shadow-sm disabled:opacity-50"
506
+ >
507
  <Send size={20}/>
508
  </button>
509
  ) : (
 
512
  onMouseUp={stopRecording}
513
  onTouchStart={startRecording}
514
  onTouchEnd={stopRecording}
515
+ onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
516
  className={`p-2 rounded-full transition-all shrink-0 select-none ${isRecording ? 'bg-red-500 text-white scale-110 shadow-lg ring-4 ring-red-200' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'}`}
517
  >
518
  {isRecording ? <StopCircle size={22}/> : <Mic size={22}/>}
 
524
  </div>
525
  </div>
526
  );
527
+ };
components/ai/WorkAssistantPanel.tsx CHANGED
@@ -144,24 +144,21 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
144
  const [enableThinking, setEnableThinking] = useState(false);
145
  const [enableSearch, setEnableSearch] = useState(false);
146
 
147
- // Chat State
148
  const [messages, setMessages] = useState<AIChatMessage[]>([]);
149
  const [textInput, setTextInput] = useState('');
150
  const [selectedImages, setSelectedImages] = useState<File[]>([]);
151
 
152
- // Document State
153
  const [docFile, setDocFile] = useState<File | null>(null);
154
  const [docContent, setDocContent] = useState<string>('');
155
  const [docLoading, setDocLoading] = useState(false);
156
 
157
  const [isProcessing, setIsProcessing] = useState(false);
158
 
159
- // UI State
160
  const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
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);
@@ -169,11 +166,8 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
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';
@@ -237,7 +231,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
237
  const currentDocText = docContent;
238
  const currentDocName = docFile?.name;
239
 
240
- // Reset Inputs
241
  setTextInput('');
242
  setSelectedImages([]);
243
  clearDoc();
@@ -248,10 +241,8 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
248
  const aiMsgId = crypto.randomUUID();
249
 
250
  try {
251
- // Process Images to Base64
252
  const base64Images = await Promise.all(currentImages.map(f => compressImage(f)));
253
 
254
- // User Message
255
  const newUserMsg: AIChatMessage = {
256
  id: userMsgId,
257
  role: 'user',
@@ -260,7 +251,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
260
  timestamp: Date.now()
261
  };
262
 
263
- // AI Placeholder
264
  const newAiMsg: AIChatMessage = {
265
  id: aiMsgId,
266
  role: 'model',
@@ -273,7 +263,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
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);
@@ -282,18 +271,14 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
282
  setIsThinkingExpanded(prev => ({ ...prev, [aiMsgId]: true }));
283
  }
284
 
285
- // Inject Image Count into System Prompt
286
  const dynamicSystemPrompt = selectedRole.prompt.replace(/{{IMAGE_COUNT}}/g, String(base64Images.length));
287
 
288
- // Construct Final Prompt
289
  let finalPrompt = currentText;
290
 
291
- // Add Doc Content
292
  if (currentDocText) {
293
  finalPrompt += `\n\n【参考文档内容 (${currentDocName})】:\n${currentDocText}\n\n请根据上述文档内容和我的要求进行创作。`;
294
  }
295
 
296
- // Add Image Logic
297
  if (base64Images.length > 0) {
298
  finalPrompt += `\n\n[系统提示] 用户上传了 ${base64Images.length} 张图片。请务必在文章中合理位置插入 (图1-图X) 格式的占位符。`;
299
  } else {
@@ -311,7 +296,7 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
311
  body: JSON.stringify({
312
  text: finalPrompt,
313
  images: base64Images,
314
- history: [], // Work assistant keeps context short
315
  enableThinking,
316
  enableSearch,
317
  overrideSystemPrompt: dynamicSystemPrompt,
@@ -355,7 +340,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
355
  aiTextAccumulated += data.content;
356
  setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, text: aiTextAccumulated, isSearching: false } : m));
357
  } else if (data.type === 'search') {
358
- // Enable search visual
359
  setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, isSearching: true } : m));
360
  } else if (data.type === 'error') {
361
  setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, text: `⚠️ 错误: ${data.message}`, isSearching: false } : m));
@@ -376,25 +360,19 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
376
  }
377
  };
378
 
379
- /**
380
- * Advanced Text Renderer
381
- * Detects (图N) or (图N-图M) patterns and renders image grids
382
- */
383
  const renderContent = (text: string, sourceImages: string[] | undefined) => {
384
  if (!sourceImages || sourceImages.length === 0) {
385
  return <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>;
386
  }
387
 
388
- // Regex to catch: (图1) or (图1-图3) or (图1) ...
389
  const splitRegex = /((?图\d+(?:-图\d+)?)?)/g;
390
  const parts = text.split(splitRegex);
391
 
392
  return (
393
  <div>
394
  {parts.map((part, idx) => {
395
- // Check if part matches image placeholder pattern
396
- const rangeMatch = part.match(/图(\d+)-图(\d+)/); // Range: 图1-图5
397
- const singleMatch = part.match(/图(\d+)/); // Single: 图1
398
 
399
  if (rangeMatch) {
400
  const start = parseInt(rangeMatch[1]);
@@ -421,7 +399,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
421
  );
422
  }
423
  } else if (singleMatch && !part.includes('-')) {
424
- // Single Image
425
  const imgIndex = parseInt(singleMatch[1]) - 1;
426
  if (sourceImages[imgIndex]) {
427
  return (
@@ -436,8 +413,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
436
  );
437
  }
438
  }
439
-
440
- // Standard text
441
  return <ReactMarkdown key={idx} remarkPlugins={[remarkGfm]} components={{p: ({node, ...props}) => <p className="mb-2 last:mb-0 inline" {...props}/>}}>{part}</ReactMarkdown>;
442
  })}
443
  </div>
@@ -448,7 +423,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
448
  <div className="flex flex-col h-full bg-slate-50 relative">
449
  {toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
450
 
451
- {/* Toolbar */}
452
  <div className="bg-white px-6 py-3 border-b border-gray-200 flex flex-wrap gap-4 items-center justify-between shadow-sm shrink-0 z-20">
453
  <div className="flex items-center gap-2">
454
  <span className="text-sm font-bold text-gray-700">工作模式:</span>
@@ -497,7 +471,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
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">
@@ -544,7 +517,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
544
  </div>
545
  )}
546
 
547
- {/* Search Status Bubble */}
548
  {msg.role === 'model' && msg.isSearching && (
549
  <div className="flex items-center gap-2 bg-blue-50 text-blue-600 px-3 py-2 rounded-xl mb-2 text-xs border border-blue-100 animate-pulse w-fit">
550
  <Globe size={14} className="animate-spin"/>
@@ -553,7 +525,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
553
  )}
554
 
555
  <div className={`p-5 rounded-2xl shadow-sm text-sm overflow-hidden relative group w-full ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white text-gray-800 border border-gray-100 rounded-tl-none'}`}>
556
- {/* User Image Preview Grid */}
557
  {msg.role === 'user' && sourceImages.length > 0 && (
558
  <div className="grid grid-cols-4 gap-2 mb-3">
559
  {sourceImages.map((img, i) => (
@@ -565,12 +536,10 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
565
  </div>
566
  )}
567
 
568
- {/* Content Rendering */}
569
  <div className={`markdown-body ${msg.role === 'user' ? 'text-white' : ''}`}>
570
  {msg.role === 'model' ? renderContent(msg.text || '', sourceImages) : <p className="whitespace-pre-wrap">{msg.text}</p>}
571
  </div>
572
 
573
- {/* Copy Button for Model */}
574
  {msg.role === 'model' && !isProcessing && (
575
  <button
576
  onClick={() => handleCopy(msg.text || '')}
@@ -596,14 +565,10 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
596
  <div ref={messagesEndRef} />
597
  </div>
598
 
599
- {/* Input Area */}
600
  <div className="bg-white border-t border-gray-200 p-4 z-20">
601
  <div className="max-w-4xl mx-auto flex flex-col gap-3">
602
-
603
- {/* Attachments Preview */}
604
  {(selectedImages.length > 0 || docFile) && (
605
  <div className="flex gap-2 overflow-x-auto pb-2 px-1 custom-scrollbar">
606
- {/* Images */}
607
  {selectedImages.map((file, idx) => (
608
  <div key={idx} className="relative w-16 h-16 shrink-0 group rounded-lg overflow-hidden border border-gray-200 shadow-sm">
609
  <img src={URL.createObjectURL(file)} className="w-full h-full object-cover" />
@@ -613,8 +578,6 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
613
  <div className="absolute bottom-0 right-0 bg-blue-600 text-white text-[9px] px-1 rounded-tl">图{idx+1}</div>
614
  </div>
615
  ))}
616
-
617
- {/* Document */}
618
  {docFile && (
619
  <div className="relative h-16 px-3 bg-indigo-50 border border-indigo-200 rounded-lg flex items-center justify-center shrink-0 min-w-[120px] max-w-[200px]">
620
  <div className="flex flex-col items-start overflow-hidden">
@@ -660,7 +623,10 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
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
  >
@@ -676,4 +642,4 @@ export const WorkAssistantPanel: React.FC<WorkAssistantPanelProps> = ({ currentU
676
  </div>
677
  </div>
678
  );
679
- };
 
144
  const [enableThinking, setEnableThinking] = useState(false);
145
  const [enableSearch, setEnableSearch] = useState(false);
146
 
 
147
  const [messages, setMessages] = useState<AIChatMessage[]>([]);
148
  const [textInput, setTextInput] = useState('');
149
  const [selectedImages, setSelectedImages] = useState<File[]>([]);
150
 
 
151
  const [docFile, setDocFile] = useState<File | null>(null);
152
  const [docContent, setDocContent] = useState<string>('');
153
  const [docLoading, setDocLoading] = useState(false);
154
 
155
  const [isProcessing, setIsProcessing] = useState(false);
156
 
 
157
  const [toast, setToast] = useState<ToastState>({ show: false, message: '', type: 'success' });
158
  const [isThinkingExpanded, setIsThinkingExpanded] = useState<Record<string, boolean>>({});
159
 
160
  const messagesEndRef = useRef<HTMLDivElement>(null);
161
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
162
  const fileInputRef = useRef<HTMLInputElement>(null);
163
  const docInputRef = useRef<HTMLInputElement>(null);
164
  const abortControllerRef = useRef<AbortController | null>(null);
 
166
  // Smart Scroll
167
  useEffect(() => {
168
  if (!scrollContainerRef.current || !messagesEndRef.current) return;
 
169
  const container = scrollContainerRef.current;
170
  const { scrollTop, scrollHeight, clientHeight } = container;
 
 
171
  const isNearBottom = scrollHeight - scrollTop - clientHeight < 150;
172
  const lastMsg = messages[messages.length - 1];
173
  const isUserMsg = lastMsg?.role === 'user';
 
231
  const currentDocText = docContent;
232
  const currentDocName = docFile?.name;
233
 
 
234
  setTextInput('');
235
  setSelectedImages([]);
236
  clearDoc();
 
241
  const aiMsgId = crypto.randomUUID();
242
 
243
  try {
 
244
  const base64Images = await Promise.all(currentImages.map(f => compressImage(f)));
245
 
 
246
  const newUserMsg: AIChatMessage = {
247
  id: userMsgId,
248
  role: 'user',
 
251
  timestamp: Date.now()
252
  };
253
 
 
254
  const newAiMsg: AIChatMessage = {
255
  id: aiMsgId,
256
  role: 'model',
 
263
 
264
  setMessages(prev => [...prev, newUserMsg, newAiMsg]);
265
 
 
266
  setTimeout(() => {
267
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
268
  }, 100);
 
271
  setIsThinkingExpanded(prev => ({ ...prev, [aiMsgId]: true }));
272
  }
273
 
 
274
  const dynamicSystemPrompt = selectedRole.prompt.replace(/{{IMAGE_COUNT}}/g, String(base64Images.length));
275
 
 
276
  let finalPrompt = currentText;
277
 
 
278
  if (currentDocText) {
279
  finalPrompt += `\n\n【参考文档内容 (${currentDocName})】:\n${currentDocText}\n\n请根据上述文档内容和我的要求进行创作。`;
280
  }
281
 
 
282
  if (base64Images.length > 0) {
283
  finalPrompt += `\n\n[系统提示] 用户上传了 ${base64Images.length} 张图片。请务必在文章中合理位置插入 (图1-图X) 格式的占位符。`;
284
  } else {
 
296
  body: JSON.stringify({
297
  text: finalPrompt,
298
  images: base64Images,
299
+ history: [],
300
  enableThinking,
301
  enableSearch,
302
  overrideSystemPrompt: dynamicSystemPrompt,
 
340
  aiTextAccumulated += data.content;
341
  setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, text: aiTextAccumulated, isSearching: false } : m));
342
  } else if (data.type === 'search') {
 
343
  setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, isSearching: true } : m));
344
  } else if (data.type === 'error') {
345
  setMessages(prev => prev.map(m => m.id === aiMsgId ? { ...m, text: `⚠️ 错误: ${data.message}`, isSearching: false } : m));
 
360
  }
361
  };
362
 
 
 
 
 
363
  const renderContent = (text: string, sourceImages: string[] | undefined) => {
364
  if (!sourceImages || sourceImages.length === 0) {
365
  return <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>;
366
  }
367
 
 
368
  const splitRegex = /((?图\d+(?:-图\d+)?)?)/g;
369
  const parts = text.split(splitRegex);
370
 
371
  return (
372
  <div>
373
  {parts.map((part, idx) => {
374
+ const rangeMatch = part.match(/图(\d+)-图(\d+)/);
375
+ const singleMatch = part.match(/图(\d+)/);
 
376
 
377
  if (rangeMatch) {
378
  const start = parseInt(rangeMatch[1]);
 
399
  );
400
  }
401
  } else if (singleMatch && !part.includes('-')) {
 
402
  const imgIndex = parseInt(singleMatch[1]) - 1;
403
  if (sourceImages[imgIndex]) {
404
  return (
 
413
  );
414
  }
415
  }
 
 
416
  return <ReactMarkdown key={idx} remarkPlugins={[remarkGfm]} components={{p: ({node, ...props}) => <p className="mb-2 last:mb-0 inline" {...props}/>}}>{part}</ReactMarkdown>;
417
  })}
418
  </div>
 
423
  <div className="flex flex-col h-full bg-slate-50 relative">
424
  {toast.show && <Toast message={toast.message} type={toast.type} onClose={()=>setToast({...toast, show: false})}/>}
425
 
 
426
  <div className="bg-white px-6 py-3 border-b border-gray-200 flex flex-wrap gap-4 items-center justify-between shadow-sm shrink-0 z-20">
427
  <div className="flex items-center gap-2">
428
  <span className="text-sm font-bold text-gray-700">工作模式:</span>
 
471
  </div>
472
  </div>
473
 
 
474
  <div ref={scrollContainerRef} className="flex-1 overflow-y-auto p-6 space-y-6 custom-scrollbar">
475
  {messages.length === 0 && (
476
  <div className="flex flex-col items-center justify-center h-full text-gray-400 opacity-60">
 
517
  </div>
518
  )}
519
 
 
520
  {msg.role === 'model' && msg.isSearching && (
521
  <div className="flex items-center gap-2 bg-blue-50 text-blue-600 px-3 py-2 rounded-xl mb-2 text-xs border border-blue-100 animate-pulse w-fit">
522
  <Globe size={14} className="animate-spin"/>
 
525
  )}
526
 
527
  <div className={`p-5 rounded-2xl shadow-sm text-sm overflow-hidden relative group w-full ${msg.role === 'user' ? 'bg-blue-600 text-white rounded-tr-none' : 'bg-white text-gray-800 border border-gray-100 rounded-tl-none'}`}>
 
528
  {msg.role === 'user' && sourceImages.length > 0 && (
529
  <div className="grid grid-cols-4 gap-2 mb-3">
530
  {sourceImages.map((img, i) => (
 
536
  </div>
537
  )}
538
 
 
539
  <div className={`markdown-body ${msg.role === 'user' ? 'text-white' : ''}`}>
540
  {msg.role === 'model' ? renderContent(msg.text || '', sourceImages) : <p className="whitespace-pre-wrap">{msg.text}</p>}
541
  </div>
542
 
 
543
  {msg.role === 'model' && !isProcessing && (
544
  <button
545
  onClick={() => handleCopy(msg.text || '')}
 
565
  <div ref={messagesEndRef} />
566
  </div>
567
 
 
568
  <div className="bg-white border-t border-gray-200 p-4 z-20">
569
  <div className="max-w-4xl mx-auto flex flex-col gap-3">
 
 
570
  {(selectedImages.length > 0 || docFile) && (
571
  <div className="flex gap-2 overflow-x-auto pb-2 px-1 custom-scrollbar">
 
572
  {selectedImages.map((file, idx) => (
573
  <div key={idx} className="relative w-16 h-16 shrink-0 group rounded-lg overflow-hidden border border-gray-200 shadow-sm">
574
  <img src={URL.createObjectURL(file)} className="w-full h-full object-cover" />
 
578
  <div className="absolute bottom-0 right-0 bg-blue-600 text-white text-[9px] px-1 rounded-tl">图{idx+1}</div>
579
  </div>
580
  ))}
 
 
581
  {docFile && (
582
  <div className="relative h-16 px-3 bg-indigo-50 border border-indigo-200 rounded-lg flex items-center justify-center shrink-0 min-w-[120px] max-w-[200px]">
583
  <div className="flex flex-col items-start overflow-hidden">
 
623
  </button>
624
  ) : (
625
  <button
626
+ onClick={handleSubmit}
627
+ onMouseDown={(e) => e.preventDefault()}
628
+ onTouchEnd={(e) => { e.preventDefault(); handleSubmit(); }}
629
+ type="button"
630
  disabled={(!textInput.trim() && selectedImages.length === 0 && !docFile) || isProcessing || docLoading}
631
  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'}`}
632
  >
 
642
  </div>
643
  </div>
644
  );
645
+ };
utils/documentParser.ts CHANGED
@@ -1,9 +1,4 @@
1
 
2
- // @ts-ignore
3
- import mammoth from 'mammoth';
4
- // @ts-ignore
5
- import * as pdfjsProxy from 'pdfjs-dist';
6
-
7
  export interface ParsedDocument {
8
  fileName: string;
9
  content: string;
@@ -33,7 +28,16 @@ const parseTxt = (file: File): Promise<string> => {
33
  });
34
  };
35
 
36
- const parseDocx = (file: File): Promise<string> => {
 
 
 
 
 
 
 
 
 
37
  return new Promise((resolve, reject) => {
38
  const reader = new FileReader();
39
  reader.onload = (e) => {
@@ -48,40 +52,39 @@ const parseDocx = (file: File): Promise<string> => {
48
  };
49
 
50
  const parsePdf = async (file: File): Promise<string> => {
51
- // Lazy initialize worker to prevent top-level await/import crashes
 
52
  try {
53
- const pdfjsLib = (pdfjsProxy as any).default || pdfjsProxy;
54
- if (pdfjsLib && !pdfjsLib.GlobalWorkerOptions.workerSrc) {
 
 
55
  pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://esm.sh/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
56
  }
57
-
58
- return new Promise((resolve, reject) => {
59
- const reader = new FileReader();
60
- reader.onload = async (e) => {
61
- try {
62
- const typedarray = new Uint8Array(e.target?.result as ArrayBuffer);
63
- if (!pdfjsLib || !pdfjsLib.getDocument) {
64
- throw new Error('PDF.js library not loaded correctly');
65
- }
66
- const pdf = await pdfjsLib.getDocument(typedarray).promise;
67
- let fullText = '';
68
-
69
- for (let i = 1; i <= pdf.numPages; i++) {
70
- const page = await pdf.getPage(i);
71
- const textContent = await page.getTextContent();
72
- const pageText = textContent.items.map((item: any) => item.str).join(' ');
73
- fullText += pageText + '\n';
74
- }
75
- resolve(fullText);
76
- } catch (err) {
77
- reject(err);
78
- }
79
- };
80
- reader.onerror = (e) => reject(e);
81
- reader.readAsArrayBuffer(file);
82
- });
83
  } catch (e) {
84
- console.error("PDF Init Error", e);
85
- throw new Error("PDF 解析库初始化失败,请检查网络或使用纯文本/Word。");
86
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  };
 
1
 
 
 
 
 
 
2
  export interface ParsedDocument {
3
  fileName: string;
4
  content: string;
 
28
  });
29
  };
30
 
31
+ const parseDocx = async (file: File): Promise<string> => {
32
+ // Dynamic import to prevent page crash if module is missing
33
+ let mammoth;
34
+ try {
35
+ // @ts-ignore
36
+ mammoth = await import('mammoth');
37
+ } catch (e) {
38
+ throw new Error('无法加载文档解析组件(mammoth),请检查网络连接。');
39
+ }
40
+
41
  return new Promise((resolve, reject) => {
42
  const reader = new FileReader();
43
  reader.onload = (e) => {
 
52
  };
53
 
54
  const parsePdf = async (file: File): Promise<string> => {
55
+ // Dynamic import
56
+ let pdfjsLib: any;
57
  try {
58
+ // @ts-ignore
59
+ const pdfjsModule = await import('pdfjs-dist');
60
+ pdfjsLib = pdfjsModule.default || pdfjsModule;
61
+ if (!pdfjsLib.GlobalWorkerOptions.workerSrc) {
62
  pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://esm.sh/pdfjs-dist@3.11.174/build/pdf.worker.min.js';
63
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  } catch (e) {
65
+ throw new Error('无法加载 PDF 解析组件,请检查网络连接。');
 
66
  }
67
+
68
+ return new Promise((resolve, reject) => {
69
+ const reader = new FileReader();
70
+ reader.onload = async (e) => {
71
+ try {
72
+ const typedarray = new Uint8Array(e.target?.result as ArrayBuffer);
73
+ const pdf = await pdfjsLib.getDocument(typedarray).promise;
74
+ let fullText = '';
75
+
76
+ for (let i = 1; i <= pdf.numPages; i++) {
77
+ const page = await pdf.getPage(i);
78
+ const textContent = await page.getTextContent();
79
+ const pageText = textContent.items.map((item: any) => item.str).join(' ');
80
+ fullText += pageText + '\n';
81
+ }
82
+ resolve(fullText);
83
+ } catch (err) {
84
+ reject(err);
85
+ }
86
+ };
87
+ reader.onerror = (e) => reject(e);
88
+ reader.readAsArrayBuffer(file);
89
+ });
90
  };