Spaces:
Running
Running
Upload 67 files
Browse files- components/Sidebar.tsx +31 -25
- components/ai/ChatPanel.tsx +16 -37
- components/ai/WorkAssistantPanel.tsx +9 -43
- utils/documentParser.ts +40 -37
components/Sidebar.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React, { useState, useEffect,
|
| 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 |
-
//
|
| 53 |
-
|
| 54 |
-
// 1. Check Standalone
|
| 55 |
const checkStandalone = () => {
|
| 56 |
-
const isStandaloneMode =
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
| 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 &&
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
|
|
|
| 216 |
</div>
|
| 217 |
-
|
|
|
|
| 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);
|
| 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;
|
| 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);
|
| 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
|
| 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 |
-
|
| 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 = '')}
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(); }}
|
| 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);
|
| 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: [],
|
| 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 |
-
|
| 396 |
-
const
|
| 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 |
-
//
|
|
|
|
| 52 |
try {
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
};
|