import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Settings as SettingsIcon, Sparkles, Menu, X, Mic, ImagePlus, Send, Loader2, Volume2, Trash2, Plus, BookOpen, Brain, GraduationCap, Coffee, History, ChevronRight, Calendar, Key, ExternalLink, Home as HomeIcon, Quote, LayoutGrid, Lightbulb, ArrowRight, Share2, Copy, Image as ImageIcon, FileText, Download } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import html2canvas from 'html2canvas'; import { TRANSLATIONS, DEFAULT_LANGUAGE } from './constants'; import { AppLanguage, ChatMode, Message, UserSettings, ChatSession, ChatScenario } from './types'; import { loadSettings, saveSettings, loadSessions, saveSessions, exportData, importData, clearData } from './services/storage'; import { streamChatResponse, transcribeAudio, generateSpeech, getModelNameForMode, formatModelName } from './services/geminiService'; import Tools from './components/Tools'; // 将常量移至顶层,修复 ReferenceError const SCENARIOS = [ ChatScenario.GENERAL, ChatScenario.READING, ChatScenario.CONCEPT, ChatScenario.RESEARCH ]; const App: React.FC = () => { const [settings, setSettingsState] = useState(loadSettings()); const [sessions, setSessions] = useState([]); const [currentSessionId, setCurrentSessionId] = useState(null); const [selectedScenario, setSelectedScenario] = useState(ChatScenario.GENERAL); const [selectedMode, setSelectedMode] = useState(ChatMode.STANDARD); const [input, setInput] = useState(''); const [activeView, setActiveView] = useState<'home' | 'chat' | 'tools' | 'settings'>('home'); const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false); const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(true); const [isProcessing, setIsProcessing] = useState(false); const [streamingContent, setStreamingContent] = useState(''); const [attachments, setAttachments] = useState<{mimeType: string, data: string, name?: string}[]>([]); const [isRecording, setIsRecording] = useState(false); const [showOnboarding, setShowOnboarding] = useState(false); const [loadingStep, setLoadingStep] = useState(0); const [showShareMenu, setShowShareMenu] = useState(false); const [installPrompt, setInstallPrompt] = useState(null); const messagesEndRef = useRef(null); const chatContainerRef = useRef(null); const fileInputRef = useRef(null); const mediaRecorderRef = useRef(null); const t = TRANSLATIONS[settings.language] || TRANSLATIONS[DEFAULT_LANGUAGE]; const randomQuote = useMemo(() => { const quotes = t.quotes || []; return quotes[Math.floor(Math.random() * quotes.length)] || { text: "", author: "" }; }, [t.quotes]); useEffect(() => { setSessions(loadSessions()); if (!settings.isOnboarded) { setShowOnboarding(true); } // PWA Install Prompt Listener const handleBeforeInstallPrompt = (e: any) => { e.preventDefault(); setInstallPrompt(e); }; const handleAppInstalled = () => { setInstallPrompt(null); }; window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.addEventListener('appinstalled', handleAppInstalled); return () => { window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.removeEventListener('appinstalled', handleAppInstalled); }; }, []); const handleFinishOnboarding = () => { setShowOnboarding(false); setSettingsState(prev => ({ ...prev, isOnboarded: true })); }; useEffect(() => { const handleResize = () => { // 在设置页面、首页或工具页面默认不开启右侧历史记录 const isDesktop = window.innerWidth >= 1024; const shouldBeOpen = isDesktop && activeView !== 'home' && activeView !== 'settings' && activeView !== 'tools'; setIsRightSidebarOpen(shouldBeOpen); }; handleResize(); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [activeView]); useEffect(() => { saveSettings(settings); }, [settings]); useEffect(() => { saveSessions(sessions); }, [sessions]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [sessions, streamingContent, currentSessionId, loadingStep, isProcessing]); // Loading Text Animation Cycle useEffect(() => { let interval: any; if (isProcessing && !streamingContent) { interval = setInterval(() => { setLoadingStep((prev) => prev + 1); }, 2500); } else { setLoadingStep(0); } return () => clearInterval(interval); }, [isProcessing, streamingContent]); const getCurrentSession = () => sessions.find(s => s.id === currentSessionId); const handleScenarioSelect = (scenario: ChatScenario) => { setSelectedScenario(scenario); setCurrentSessionId(null); setActiveView('chat'); setIsLeftSidebarOpen(false); }; const deleteSession = (e: React.MouseEvent, id: string) => { e.stopPropagation(); if (window.confirm(t.confirmDelete)) { setSessions(prev => prev.filter(s => s.id !== id)); if (currentSessionId === id) setCurrentSessionId(null); } }; const getScenarioIcon = (scenario?: ChatScenario) => { switch (scenario) { case ChatScenario.READING: return ; case ChatScenario.CONCEPT: return ; case ChatScenario.RESEARCH: return ; default: return ; } }; const getLoadingText = (scenario?: ChatScenario) => { // 简单的场景化加载文案,根据需要可扩展到 constants.ts const stepsMap: Record = { [ChatScenario.READING]: [ "正在翻阅经典著作...", "分析历史背景...", "解读理论脉络...", "构建学术回答..." ], [ChatScenario.CONCEPT]: [ "正在解析核心定义...", "追溯词源...", "对比相关理论...", "生成应用案例..." ], [ChatScenario.RESEARCH]: [ "正在评估研究问题...", "回顾方法论框架...", "检查伦理考量...", "完善研究设计..." ], [ChatScenario.GENERAL]: [ "正在思考...", "连接社会学视角...", "组织语言...", "生成回答..." ] }; // English fallbacks if needed, simplified for this snippet const isEn = settings.language === AppLanguage.EN; if (isEn) { const stepsMapEn: Record = { [ChatScenario.READING]: ["Consulting classics...", "Analyzing context...", "Interpreting text..."], [ChatScenario.CONCEPT]: ["Defining terms...", "Tracing etymology...", "Comparing frameworks..."], [ChatScenario.RESEARCH]: ["Reviewing methodology...", "Checking ethics...", "Designing structure..."], [ChatScenario.GENERAL]: ["Thinking...", "Connecting perspectives...", "Drafting response..."] }; const texts = stepsMapEn[scenario || ChatScenario.GENERAL] || stepsMapEn[ChatScenario.GENERAL]; return texts[loadingStep % texts.length]; } const texts = stepsMap[scenario || ChatScenario.GENERAL] || stepsMap[ChatScenario.GENERAL]; return texts[loadingStep % texts.length]; }; const getGroupedSessions = () => { const groups: { [key: string]: ChatSession[] } = { [t.today]: [], [t.yesterday]: [], [t.last7Days]: [], [t.older]: [] }; const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const yesterday = today - 86400000; const lastWeek = today - 86400000 * 7; sessions.filter(s => (s.scenario || ChatScenario.GENERAL) === selectedScenario).forEach(s => { if (s.createdAt >= today) groups[t.today].push(s); else if (s.createdAt >= yesterday) groups[t.yesterday].push(s); else if (s.createdAt >= lastWeek) groups[t.last7Days].push(s); else groups[t.older].push(s); }); return groups; }; const handleSendMessage = async () => { if ((!input.trim() && attachments.length === 0) || isProcessing) return; let session = getCurrentSession(); let isNewSession = false; if (!session) { isNewSession = true; const scenarioConfig = t.scenarios[selectedScenario]; session = { id: Date.now().toString(), title: input.slice(0, 30) || t.newChat, messages: [{ id: (Date.now() - 100).toString(), role: 'model', content: scenarioConfig.greeting, timestamp: Date.now() - 100, model: getModelNameForMode(selectedMode) // Assuming greeting uses standard/selected mode logic mentally, or just informational }], mode: selectedMode, scenario: selectedScenario, createdAt: Date.now() }; } const userMsg: Message = { id: Date.now().toString(), role: 'user', content: input, timestamp: Date.now(), attachments: attachments.map(a => ({ type: 'image', ...a })) }; const updatedMessages = [...session!.messages, userMsg]; if (isNewSession) { setSessions(prev => [{ ...session!, messages: updatedMessages }, ...prev]); setCurrentSessionId(session!.id); } else { setSessions(prev => prev.map(s => s.id === session!.id ? { ...s, messages: updatedMessages } : s)); } setInput(''); setAttachments([]); setIsProcessing(true); setStreamingContent(''); setLoadingStep(0); // Reset animation step try { let fullResponse = ''; let groundingData: any = null; const usedModel = getModelNameForMode(session!.mode); await streamChatResponse( updatedMessages, userMsg.content, session!.mode, settings.language, session!.scenario || ChatScenario.GENERAL, userMsg.attachments as any, (text, grounding) => { fullResponse += text; setStreamingContent(fullResponse); if (grounding) groundingData = grounding; } ); const modelMsg: Message = { id: (Date.now() + 1).toString(), role: 'model', content: fullResponse, timestamp: Date.now(), groundingMetadata: groundingData, model: usedModel }; setSessions(prev => prev.map(s => s.id === session!.id ? { ...s, messages: [...updatedMessages, modelMsg] } : s)); } catch (err: any) { console.error(err); const errorMsg: Message = { id: Date.now().toString(), role: 'model', content: err.message || t.apiError, timestamp: Date.now() }; setSessions(prev => prev.map(s => s.id === session!.id ? { ...s, messages: [...updatedMessages, errorMsg] } : s)); } finally { setIsProcessing(false); setStreamingContent(''); } }; const handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onloadend = () => { const base64Data = (reader.result as string).split(',')[1]; setAttachments(prev => [...prev, { mimeType: file.type, data: base64Data, name: file.name }]); }; reader.readAsDataURL(file); e.target.value = ''; }; const handleRecordAudio = async () => { if (isRecording) { mediaRecorderRef.current?.stop(); setIsRecording(false); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mediaRecorder = new MediaRecorder(stream); mediaRecorderRef.current = mediaRecorder; const chunks: BlobPart[] = []; mediaRecorder.ondataavailable = (e) => chunks.push(e.data); mediaRecorder.onstop = async () => { const blob = new Blob(chunks, { type: 'audio/webm' }); const reader = new FileReader(); reader.onloadend = async () => { const base64 = (reader.result as string).split(',')[1]; setIsProcessing(true); try { const text = await transcribeAudio(base64, 'audio/webm', settings.language); setInput(prev => prev + " " + text); } catch (e) { alert(t.transcriptionFail); } finally { setIsProcessing(false); } }; reader.readAsDataURL(blob); stream.getTracks().forEach(track => track.stop()); }; mediaRecorder.start(); setIsRecording(true); } catch (e) { alert(t.micError); } }; const handleImport = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const success = await importData(file); if (success) { alert(t.importSuccess); setSessions(loadSessions()); setSettingsState(loadSettings()); } else { alert(t.importFail); } e.target.value = ''; }; const handleOpenSelectKey = async () => { if (typeof (window as any).aistudio !== 'undefined') { await (window as any).aistudio.openSelectKey(); } }; const handleInstallClick = async () => { if (!installPrompt) return; installPrompt.prompt(); const { outcome } = await installPrompt.userChoice; if (outcome === 'accepted') { setInstallPrompt(null); } }; const handleShareText = () => { const session = getCurrentSession(); if (!session) return; const text = session.messages.map(m => `${m.role === 'user' ? 'User' : 'SocioPal'}: ${m.content}` ).join('\n\n'); navigator.clipboard.writeText(text).then(() => { alert("对话已复制到剪贴板!"); setShowShareMenu(false); }); }; const handleDownloadText = () => { const session = getCurrentSession(); if (!session) return; const text = session.messages.map(m => `[${new Date(m.timestamp).toLocaleString()}] ${m.role === 'user' ? 'User' : 'AI'}:\n${m.content}\n` ).join('\n-------------------\n\n'); const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `sociopal-chat-${Date.now()}.txt`; document.body.appendChild(link); link.click(); document.body.removeChild(link); setShowShareMenu(false); }; const handleShareImage = async () => { if (!chatContainerRef.current) return; setIsProcessing(true); // Temporarily show loading state const originalElement = chatContainerRef.current; try { // 1. 克隆节点 const clone = originalElement.cloneNode(true) as HTMLElement; const width = originalElement.clientWidth; // 2. 设置克隆节点样式以展示全部内容 Object.assign(clone.style, { position: 'absolute', top: '-9999px', left: '-9999px', width: `${width}px`, height: 'auto', maxHeight: 'none', overflow: 'visible', zIndex: '-1000', background: '#f8fafc', // slate-50 }); document.body.appendChild(clone); // 3. 简单延时等待渲染 await new Promise(r => setTimeout(r, 100)); // 4. 使用 html2canvas 截图 const canvas = await html2canvas(clone, { backgroundColor: '#f8fafc', useCORS: true, logging: false, scale: 2, // 提升清晰度 width: width, height: clone.scrollHeight, windowWidth: width, windowHeight: clone.scrollHeight }); // 5. 清理克隆节点 document.body.removeChild(clone); const image = canvas.toDataURL("image/png"); const link = document.createElement('a'); link.href = image; link.download = `sociopal-chat-${Date.now()}.png`; link.click(); setShowShareMenu(false); } catch (error) { console.error("Image generation failed", error); alert("生成图片失败,请重试。"); } finally { setIsProcessing(false); } }; const playTTS = async (text: string) => { try { const buffer = await generateSpeech(text); const ctx = new (window.AudioContext || (window as any).webkitAudioContext)({sampleRate: 24000}); const source = ctx.createBufferSource(); source.buffer = buffer; source.connect(ctx.destination); source.start(0); } catch (e) { console.error(e); } }; const currentSession = getCurrentSession(); const groupedSessions = getGroupedSessions(); const activeMode = currentSession ? currentSession.mode : selectedMode; return ( // 使用 100dvh 适配移动端浏览器视口,防止被地址栏遮挡
{/* ONBOARDING MODAL */} {showOnboarding && (

{t.appName} - {t.tagline}

  • 1

    {t.onboarding.step1}

  • 2

    {t.onboarding.step2}

  • 3

    {t.onboarding.step3}

)} {/* Mobile Backdrop for Left Sidebar */} {isLeftSidebarOpen && (
setIsLeftSidebarOpen(false)} /> )} {/* Mobile Backdrop for Right Sidebar */} {isRightSidebarOpen && (
setIsRightSidebarOpen(false)} /> )} {/* LEFT SIDEBAR */} {/* MAIN CONTENT */}
{activeView === 'chat' && (
{getScenarioIcon(currentSession ? currentSession.scenario : selectedScenario)} {t.scenarios[(currentSession ? currentSession.scenario : selectedScenario) || ChatScenario.GENERAL].title}
)} {activeView === 'home' && (
{t.home}
)}
{activeView === 'chat' && ( <>
{[ { mode: ChatMode.STANDARD, label: t.modeStandard, color: 'text-blue-600' }, { mode: ChatMode.DEEP, label: t.modeDeep, color: 'text-purple-600' }, { mode: ChatMode.FAST, label: t.modeFast, color: 'text-green-600' } ].map(m => ( ))}
{/* Share Button */}
{showShareMenu && (
)} {/* Backdrop to close menu */} {showShareMenu && (
setShowShareMenu(false)} /> )}
)} {activeView !== 'home' && activeView !== 'settings' && activeView !== 'tools' && ( )}
{activeView === 'home' && (

{t.homeWelcome}

{t.tagline} {t.homeDesc}

{t.homeQuoteTitle}
“{randomQuote.text}”
—— {randomQuote.author}

{t.homeFeatureTitle}

{SCENARIOS.map((scenario, idx) => ( ))}
)} {activeView === 'chat' && (
{!currentSession ? (
{getScenarioIcon(selectedScenario)}

{t.scenarios[selectedScenario].title}

{t.scenarios[selectedScenario].greeting}

) : (
{currentSession.messages.map((msg) => (
{msg.role === 'model' && (
{getScenarioIcon(currentSession.scenario)}
)}
{msg.attachments?.map((att, i) => (
attachment
))}
{msg.content}
{new Date(msg.timestamp).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} {msg.role === 'model' && ( <> {msg.model && ( <> {formatModelName(msg.model)} )} )}
))} {/* 思考中(无内容)的动画状态 */} {isProcessing && !streamingContent && (
{getScenarioIcon(currentSession?.scenario)}
{getLoadingText(currentSession?.scenario)}
)} {/* 正在生成(有内容)的状态 */} {isProcessing && streamingContent && (
{streamingContent}
{currentSession?.mode === ChatMode.DEEP ? t.thinking : t.generating}
)}
)}
{/* 输入框区域增加底部安全距离适配 pb-[max(1rem,env(safe-area-inset-bottom))] */}