diff --git a/App.tsx b/App.tsx index a5b36dc..28bb99c 100644 --- a/App.tsx +++ b/App.tsx @@ -25,13 +25,19 @@ import { Quote, LayoutGrid, Lightbulb, - ArrowRight + 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 } from './services/geminiService'; +import { streamChatResponse, transcribeAudio, generateSpeech, getModelNameForMode, formatModelName } from './services/geminiService'; import Tools from './components/Tools'; // 将常量移至顶层,修复 ReferenceError @@ -57,8 +63,12 @@ const App: React.FC = () => { 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); @@ -74,6 +84,24 @@ const App: React.FC = () => { 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 = () => { @@ -83,9 +111,9 @@ const App: React.FC = () => { useEffect(() => { const handleResize = () => { - // 在设置页面或首页默认不开启右侧历史记录 + // 在设置页面、首页或工具页面默认不开启右侧历史记录 const isDesktop = window.innerWidth >= 1024; - const shouldBeOpen = isDesktop && activeView !== 'home' && activeView !== 'settings'; + const shouldBeOpen = isDesktop && activeView !== 'home' && activeView !== 'settings' && activeView !== 'tools'; setIsRightSidebarOpen(shouldBeOpen); }; handleResize(); @@ -97,7 +125,20 @@ const App: React.FC = () => { useEffect(() => { saveSessions(sessions); }, [sessions]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [sessions, streamingContent, currentSessionId]); + }, [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); @@ -125,6 +166,52 @@ const App: React.FC = () => { } }; + 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]: [] @@ -159,7 +246,8 @@ const App: React.FC = () => { id: (Date.now() - 100).toString(), role: 'model', content: scenarioConfig.greeting, - timestamp: Date.now() - 100 + timestamp: Date.now() - 100, + model: getModelNameForMode(selectedMode) // Assuming greeting uses standard/selected mode logic mentally, or just informational }], mode: selectedMode, scenario: selectedScenario, @@ -188,10 +276,12 @@ const App: React.FC = () => { setAttachments([]); setIsProcessing(true); setStreamingContent(''); + setLoadingStep(0); // Reset animation step try { let fullResponse = ''; let groundingData: any = null; + const usedModel = getModelNameForMode(session!.mode); await streamChatResponse( updatedMessages, @@ -212,7 +302,8 @@ const App: React.FC = () => { role: 'model', content: fullResponse, timestamp: Date.now(), - groundingMetadata: groundingData + groundingMetadata: groundingData, + model: usedModel }; setSessions(prev => prev.map(s => s.id === session!.id ? { @@ -299,6 +390,106 @@ const App: React.FC = () => { } }; + 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); @@ -315,7 +506,8 @@ const App: React.FC = () => { const activeMode = currentSession ? currentSession.mode : selectedMode; return ( -
+ // 使用 100dvh 适配移动端浏览器视口,防止被地址栏遮挡 +
{/* ONBOARDING MODAL */} {showOnboarding && (
@@ -350,9 +542,25 @@ const App: React.FC = () => {
)} + {/* Mobile Backdrop for Left Sidebar */} + {isLeftSidebarOpen && ( +
setIsLeftSidebarOpen(false)} + /> + )} + + {/* Mobile Backdrop for Right Sidebar */} + {isRightSidebarOpen && ( +
setIsRightSidebarOpen(false)} + /> + )} + {/* LEFT SIDEBAR */}
-
+ {/* 底部菜单适配安全区域 */} +
{activeView === 'chat' && ( -
- {getScenarioIcon(currentSession ? currentSession.scenario : selectedScenario)} - {t.scenarios[(currentSession ? currentSession.scenario : selectedScenario) || ChatScenario.GENERAL].title} +
+ {getScenarioIcon(currentSession ? currentSession.scenario : selectedScenario)} + {t.scenarios[(currentSession ? currentSession.scenario : selectedScenario) || ChatScenario.GENERAL].title}
)} {activeView === 'home' && ( @@ -443,9 +652,10 @@ const App: React.FC = () => { )}
-
+
{activeView === 'chat' && ( -
+ <> +
{[ { mode: ChatMode.STANDARD, label: t.modeStandard, color: 'text-blue-600' }, { mode: ChatMode.DEEP, label: t.modeDeep, color: 'text-purple-600' }, @@ -457,14 +667,44 @@ const App: React.FC = () => { if(currentSession) setSessions(s => s.map(sess => sess.id === currentSessionId ? {...sess, mode: m.mode} : sess)); else setSelectedMode(m.mode); }} - className={`px-3 py-1 text-xs font-medium rounded-md transition-all active:scale-95 ${activeMode === m.mode ? `bg-white shadow ${m.color}` : 'text-slate-500 hover:bg-slate-200'}`} + className={`px-2 sm:px-3 py-1 text-[10px] sm:text-xs font-medium rounded-md transition-all active:scale-95 whitespace-nowrap flex-shrink-0 ${activeMode === m.mode ? `bg-white shadow ${m.color}` : 'text-slate-500 hover:bg-slate-200'}`} > {m.label} ))}
+ + {/* Share Button */} +
+ + {showShareMenu && ( +
+ + + +
+ )} + {/* Backdrop to close menu */} + {showShareMenu && ( +
setShowShareMenu(false)} /> + )} +
+ )} - {activeView !== 'home' && activeView !== 'settings' && ( + + {activeView !== 'home' && activeView !== 'settings' && activeView !== 'tools' && ( @@ -475,7 +715,7 @@ const App: React.FC = () => {
{activeView === 'home' && (
-
+

{t.homeWelcome} @@ -545,7 +785,7 @@ const App: React.FC = () => { {activeView === 'chat' && (
-
+
{!currentSession ? (
@@ -581,12 +821,43 @@ const App: React.FC = () => {
{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 && (
@@ -605,7 +876,8 @@ const App: React.FC = () => { )}
-
+ {/* 输入框区域增加底部安全距离适配 pb-[max(1rem,env(safe-area-inset-bottom))] */} +
@@ -631,10 +903,25 @@ const App: React.FC = () => {
)} - {activeView === 'tools' &&
} + {activeView === 'tools' &&
} {activeView === 'settings' && ( -
+
+ {installPrompt && ( +
+
+

{t.installApp}

+

{t.installAppDesc}

+
+ +
+ )}

{t.settings}

@@ -707,16 +994,30 @@ const App: React.FC = () => { {/* 设置视图下不显示右侧侧边栏 */}