diff --git a/App.tsx b/App.tsx index b74183b..a5b36dc 100644 --- a/App.tsx +++ b/App.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect, useRef } from 'react'; + +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Settings as SettingsIcon, - MessageSquare, Sparkles, Menu, X, @@ -18,8 +18,14 @@ import { Coffee, History, ChevronRight, - ChevronLeft, - Calendar + Calendar, + Key, + ExternalLink, + Home as HomeIcon, + Quote, + LayoutGrid, + Lightbulb, + ArrowRight } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import { TRANSLATIONS, DEFAULT_LANGUAGE } from './constants'; @@ -28,81 +34,78 @@ import { loadSettings, saveSettings, loadSessions, saveSessions, exportData, imp import { streamChatResponse, transcribeAudio, generateSpeech } from './services/geminiService'; import Tools from './components/Tools'; +// 将常量移至顶层,修复 ReferenceError +const SCENARIOS = [ + ChatScenario.GENERAL, + ChatScenario.READING, + ChatScenario.CONCEPT, + ChatScenario.RESEARCH +]; + const App: React.FC = () => { - // State const [settings, setSettingsState] = useState(loadSettings()); const [sessions, setSessions] = useState([]); const [currentSessionId, setCurrentSessionId] = useState(null); - - // New State for Lazy Creation const [selectedScenario, setSelectedScenario] = useState(ChatScenario.GENERAL); - // Track selected mode for the upcoming session const [selectedMode, setSelectedMode] = useState(ChatMode.STANDARD); - const [input, setInput] = useState(''); - const [activeView, setActiveView] = useState<'chat' | 'tools' | 'settings'>('chat'); - - // Sidebar States + 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); - // Refs const messagesEndRef = useRef(null); const fileInputRef = useRef(null); const mediaRecorderRef = useRef(null); const t = TRANSLATIONS[settings.language] || TRANSLATIONS[DEFAULT_LANGUAGE]; - // Effects + const randomQuote = useMemo(() => { + const quotes = t.quotes || []; + return quotes[Math.floor(Math.random() * quotes.length)] || { text: "", author: "" }; + }, [t.quotes]); + useEffect(() => { - const loadedSessions = loadSessions(); - setSessions(loadedSessions); - // On load, do NOT select a session, let user start fresh or pick one + setSessions(loadSessions()); + if (!settings.isOnboarded) { + setShowOnboarding(true); + } }, []); - // Responsive Sidebar Logic + const handleFinishOnboarding = () => { + setShowOnboarding(false); + setSettingsState(prev => ({ ...prev, isOnboarded: true })); + }; + useEffect(() => { const handleResize = () => { - if (window.innerWidth < 1024) { - setIsRightSidebarOpen(false); - } else { - setIsRightSidebarOpen(true); - } + // 在设置页面或首页默认不开启右侧历史记录 + const isDesktop = window.innerWidth >= 1024; + const shouldBeOpen = isDesktop && activeView !== 'home' && activeView !== 'settings'; + setIsRightSidebarOpen(shouldBeOpen); }; - - // Set initial state handleResize(); - window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); - }, []); - - useEffect(() => { - saveSettings(settings); - }, [settings]); - - useEffect(() => { - saveSessions(sessions); - }, [sessions]); + }, [activeView]); + useEffect(() => { saveSettings(settings); }, [settings]); + useEffect(() => { saveSessions(sessions); }, [sessions]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [sessions, streamingContent, currentSessionId]); - // Helpers const getCurrentSession = () => sessions.find(s => s.id === currentSessionId); - // Instead of creating session immediately, we prepare the UI const handleScenarioSelect = (scenario: ChatScenario) => { setSelectedScenario(scenario); - setCurrentSessionId(null); // Clear current session to show "New Chat" state + setCurrentSessionId(null); setActiveView('chat'); - setIsLeftSidebarOpen(false); // Close mobile menu if open + setIsLeftSidebarOpen(false); }; const deleteSession = (e: React.MouseEvent, id: string) => { @@ -118,84 +121,52 @@ const App: React.FC = () => { case ChatScenario.READING: return ; case ChatScenario.CONCEPT: return ; case ChatScenario.RESEARCH: return ; - case ChatScenario.GENERAL: default: return ; } }; - const SCENARIOS = [ - ChatScenario.GENERAL, - ChatScenario.READING, - ChatScenario.CONCEPT, - ChatScenario.RESEARCH - ]; - - // Group Sessions by Date AND Filter by current Scenario const getGroupedSessions = () => { const groups: { [key: string]: ChatSession[] } = { - 'Today': [], - 'Yesterday': [], - 'Previous 7 Days': [], - 'Older': [] + [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; - // Filter sessions to only show those belonging to the selected scenario - const filteredSessions = sessions.filter(s => { - // Default to GENERAL if scenario is undefined (legacy data) - const sessionScenario = s.scenario || ChatScenario.GENERAL; - return sessionScenario === selectedScenario; + 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); }); - - filteredSessions.forEach(s => { - if (s.createdAt >= today) groups['Today'].push(s); - else if (s.createdAt >= yesterday) groups['Yesterday'].push(s); - else if (s.createdAt >= lastWeek) groups['Previous 7 Days'].push(s); - else groups['Older'].push(s); - }); - return groups; }; - // Handlers const handleSendMessage = async () => { if ((!input.trim() && attachments.length === 0) || isProcessing) return; let session = getCurrentSession(); let isNewSession = false; - // LAZY CREATION LOGIC: - // If no session exists, create one now if (!session) { isNewSession = true; const scenarioConfig = t.scenarios[selectedScenario]; - const initialGreeting = scenarioConfig.greeting; - - const newSession: ChatSession = { + session = { id: Date.now().toString(), - title: input.slice(0, 30) || t.newChat, // Set title immediately from first prompt - messages: [ - { - id: (Date.now() - 100).toString(), - role: 'model', - content: initialGreeting, // Inject the greeting that was shown in UI - timestamp: Date.now() - 100 - } - ], - mode: selectedMode, // Use the selected mode + title: input.slice(0, 30) || t.newChat, + messages: [{ + id: (Date.now() - 100).toString(), + role: 'model', + content: scenarioConfig.greeting, + timestamp: Date.now() - 100 + }], + mode: selectedMode, scenario: selectedScenario, createdAt: Date.now() }; - - // Update local variable to use immediately - session = newSession; } - // Prepare User Message const userMsg: Message = { id: Date.now().toString(), role: 'user', @@ -204,14 +175,10 @@ const App: React.FC = () => { attachments: attachments.map(a => ({ type: 'image', ...a })) }; - // Optimistic UI Update const updatedMessages = [...session!.messages, userMsg]; if (isNewSession) { - setSessions(prev => [ - { ...session!, messages: updatedMessages }, - ...prev - ]); + setSessions(prev => [{ ...session!, messages: updatedMessages }, ...prev]); setCurrentSessionId(session!.id); } else { setSessions(prev => prev.map(s => s.id === session!.id ? { ...s, messages: updatedMessages } : s)); @@ -253,32 +220,22 @@ const App: React.FC = () => { messages: [...updatedMessages, modelMsg] } : s)); - } catch (err) { + } catch (err: any) { console.error(err); - const errorMsg: Message = { - id: Date.now().toString(), - role: 'model', - content: t.apiError, - timestamp: Date.now() - }; - setSessions(prev => prev.map(s => s.id === session!.id ? { - ...s, - messages: [...updatedMessages, errorMsg] - } : s)); + 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(''); } }; - // ... (Keep existing helpers: handleFileUpload, handleRecordAudio, playTTS, handleImport) const handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onloadend = () => { - const base64String = reader.result as string; - const base64Data = base64String.split(',')[1]; + const base64Data = (reader.result as string).split(',')[1]; setAttachments(prev => [...prev, { mimeType: file.type, data: base64Data, name: file.name }]); }; reader.readAsDataURL(file); @@ -304,10 +261,9 @@ const App: React.FC = () => { const base64 = (reader.result as string).split(',')[1]; setIsProcessing(true); try { - const text = await transcribeAudio(base64, 'audio/webm'); + const text = await transcribeAudio(base64, 'audio/webm', settings.language); setInput(prev => prev + " " + text); } catch (e) { - console.error(e); alert(t.transcriptionFail); } finally { setIsProcessing(false); @@ -319,54 +275,85 @@ const App: React.FC = () => { mediaRecorder.start(); setIsRecording(true); } catch (e) { - console.error("Mic error", 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 playTTS = async (text: string) => { try { const buffer = await generateSpeech(text); - const ctx = new (window.AudioContext || (window as any).webkitAudioContext)(); + 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("TTS Error", e); - } - }; - - const handleImport = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - const success = await importData(file); - if (success) { - setSettingsState(loadSettings()); - setSessions(loadSessions()); - alert(t.importSuccess); - } else { - alert(t.importFail); - } - } + } catch (e) { console.error(e); } }; const currentSession = getCurrentSession(); const groupedSessions = getGroupedSessions(); - - // Helper to get active mode for UI display const activeMode = currentSession ? currentSession.mode : selectedMode; return ( -
- - {/* 1. LEFT SIDEBAR (Navigation) */} - {isLeftSidebarOpen && ( -
setIsLeftSidebarOpen(false)} /> +
+ {/* ONBOARDING MODAL */} + {showOnboarding && ( +
+
+
+ +
+
+

{t.appName} - {t.tagline}

+
    +
  • + 1 +

    {t.onboarding.step1}

    +
  • +
  • + 2 +

    {t.onboarding.step2}

    +
  • +
  • + 3 +

    {t.onboarding.step3}

    +
  • +
+
+ +
+
)} + + {/* LEFT SIDEBAR */} - {/* 2. MAIN CONTENT (Center) */} + {/* MAIN CONTENT */}
- - {/* Header */} -
+
- {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' && ( +
+ + {t.home} +
+ )}
- {/* Mode Switcher */} {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 => ( + + ))}
)} - - {/* Toggle Right Sidebar - Only visible in Chat View */} - {activeView === 'chat' && ( - )}
- {/* View Content */}
+ {activeView === 'home' && ( +
+
+
+

+ {t.homeWelcome} +

+

+ {t.tagline} {t.homeDesc} +

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

+ + {t.homeFeatureTitle} +

+
+ {SCENARIOS.map((scenario, idx) => ( + + ))} +
+
+
+
+ )} + {activeView === 'chat' && (
- - {/* Messages Area */}
{!currentSession ? ( - /* Welcome State (No session yet) */ -
-
+
+
{getScenarioIcon(selectedScenario)}

{t.scenarios[selectedScenario].title}

-

{t.scenarios[selectedScenario].greeting}

+

{t.scenarios[selectedScenario].greeting}

) : ( - /* Active Session Messages */ - <> - {currentSession.messages.map((msg, idx) => ( -
+
+ {currentSession.messages.map((msg) => ( +
{msg.role === 'model' && (
-
+
{getScenarioIcon(currentSession.scenario)}
)}
{msg.attachments?.map((att, i) => ( -
- attachment +
+ attachment
))} - -
- - {msg.content} - +
+ {msg.content} +
- -
- {new Date(msg.timestamp).toLocaleTimeString()} +
+ {new Date(msg.timestamp).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})} {msg.role === 'model' && ( - <> - - {msg.groundingMetadata?.groundingChunks && msg.groundingMetadata.groundingChunks.length > 0 && ( - - - {t.searchSources} - - )} - + )}
- - {msg.role === 'model' && msg.groundingMetadata?.groundingChunks && ( -
-

{t.searchSources}:

- -
- )}
))} - {isProcessing && streamingContent && ( -
-
-
- {getScenarioIcon(currentSession.scenario)} -
-
+
+
- - {streamingContent} - -
- +
+ {streamingContent} +
+
{currentSession?.mode === ChatMode.DEEP ? t.thinking : t.generating}
)}
- +
)}
- {/* Input Area */} -
+
- {attachments.length > 0 && ( -
- {attachments.map((a, i) => ( -
- preview - -
- ))} -
- )} -
+
- - +